Building a macOS app to know when my Mac is thermal throttling

Table of Contents
This is the story about how I built MacThrottle.
I’ve been very happy with my M2 MacBook Air for the past few years. However, when using an external display, especially a very demanding one like a 4K 120Hz display, I’ve noticed it started struggling more. Since it lacks fans, you can’t hear it struggling, but you can feel it as everything becomes very slow or unresponsive: that’s when thermal throttling kicks in.
I know it’s thermal throttling because I can see in iStat Menus that my CPU usage is 100% while the power usage in watts goes down.
It’s even more obvious with MX Power Gadget: You can see the power usage and frequency of the performance core dropping, as usage keeps being 100%:

Getting the thermal state programmatically #
This was a wilder ride than I expected. It’s possible to know programmatically if the Mac is throttled, because macOS exposes this in various but inconsistent ways.
The approach that Apple recommends is to use ProcessInfo.thermalState from Foundation:
➜ ~ swift -e 'import Foundation; print(["nominal", "fair", "serious", "critical"][ProcessInfo.processInfo.thermalState.rawValue])'
nominal
Sounds good, right? However, I knew that another tool could provide this information, though it needed root: powermetrics.
➜ ~ sudo powermetrics -s thermal
Password:
Machine model: Mac14,2
OS version: 25B78
Boot arguments:
Boot time: Sun Nov 23 10:19:29 2025
*** Sampled system activity (Wed Dec 17 09:48:34 2025 +0100) (5001.07ms elapsed) ***
**** Thermal pressure ****
Current pressure level: Nominal
*** Sampled system activity (Wed Dec 17 09:48:39 2025 +0100) (5001.25ms elapsed) ***
**** Thermal pressure ****
Current pressure level: Nominal
(yes the output has that many newlines)
Both report the pressure level to be “nominal”, they must be the same…right?
After running a few stress tests stress-ng --cpu 0 -t 600, I started to see the two values diverge!
For some reason, the granularity is different between ProcessInfo.thermalState and powermetrics. They have a different amount of possible states and they don’t line up.
Here is my empirical experience:
ProcessInfo.thermalState | powermetrics |
|---|---|
| nominal | nominal |
| fair | moderate |
| fair | heavy |
I never managed to hit these states, so I don’t know if they match, but they’re technically defined:
ProcessInfo.thermalState | powermetrics |
|---|---|
| serious | trapping |
| critical | sleeping |
In practice, when my Mac starts getting hot, from the powermetrics perspective it goes into moderate, and when it starts throttling, it goes into heavy. The problem is that with ProcessInfo, both are covered by the fair state, so it’s not really useful to know when the Mac is actually throttling. ☹️
I thought maybe this was an iOS vs macOS thing? But Apple references it in the macOS docs as well. Maybe it was more consistent on Intel Macs?
I stumbled upon this article from Dave MacLachlan, a Googler working on Apple stuff, in 2020. I learned that there are other CLI tools to get thermal data, but they don’t seem to work on my Apple Silicon MacBook:
➜ sudo thermal levels
Thermal levels are unsupported on this machine.
➜ sudo pmset -g thermlog
Note: No thermal warning level has been recorded
Note: No performance warning level has been recorded
Note: No CPU power status has been recorded
^C
I also learned that the data powermetrics shows is actually coming from thermald. And thermald writes the current thermal pressure to the Darwin notification system (notifyd)!
➜ notifyutil -g com.apple.system.thermalpressurelevel
com.apple.system.thermalpressurelevel 0
The cool thing here is that it doesn’t require root!
It turns out I can subscribe to a Darwin event to get the (good) thermal state!
Here is a snippet to get it in Swift:
import Foundation
@_silgen_name("notify_register_check")
private func notify_register_check(
_ name: UnsafePointer<CChar>, _ token: UnsafeMutablePointer<Int32>
) -> UInt32
@_silgen_name("notify_get_state")
private func notify_get_state(_ token: Int32, _ state: UnsafeMutablePointer<UInt64>) -> UInt32
@_silgen_name("notify_cancel")
private func notify_cancel(_ token: Int32) -> UInt32
let notifyOK: UInt32 = 0
let name = "com.apple.system.thermalpressurelevel"
var token: Int32 = 0
let reg = name.withCString { notify_register_check($0, &token) }
guard reg == notifyOK else { fatalError("notify_register_check failed: \(reg)") }
defer { _ = notify_cancel(token) }
var state: UInt64 = 0
let got = notify_get_state(token, &state)
guard got == notifyOK else { fatalError("notify_get_state failed: \(got)") }
let label =
switch state {
case 0: "nominal"
case 1: "moderate"
case 2: "heavy"
case 3: "trapping"
case 4: "sleeping"
default: "unknown(\(state))"
}
print("\(state) \(label)")
Prints:
➜ ~ swift thermal.swift
0 nominal
Now that I had a useful value to work with, it was time to build the app.
Building MacThrottle #
Armed with Opus 4.5, I set out to build a little menu bar app where I could see, at a glance, if my Apple Silicon die was trying to save itself from crossing 110°C. I called it MacThrottle.
I built a simple SwiftUI app for the menu bar that shows me the status in a superbly original thermometer icon. The thermometer is filled depending on the thermal state, and its color changes from green to red. I have like 20 menu bar icons and they’re all monochromatic, so the color in the thermometer is very subtle to keep things consistent.
The app is a simple SwiftUI app. Apple provides a scene called MenuBarExtra to render a menu bar control. It was simpler than I expected! To make it a pure menu bar app with no dock icon, you just need to set LSUIElement to true in Info.plist.

First approach: root helper for powermetrics #
I explained the various approaches to get the thermal pressure level in the previous section. But when I was building the app, I discovered later on that thermald was publishing the thermal state to notifyd. So at first, I thought I had to use powermetrics to get useful thermal state changes. Since that unfortunately requires root access, the app needed root access too.
To reduce the scope of what runs as root, I did not run the app itself as root. Instead, the app does not work by default, but it gives you the option to install a helper. It does this through an AppleScript with administrator privileges to prompt for access.
The helper is just a bash script ran as a launchd daemon:
➜ ~ cat /Library/LaunchDaemons/com.macthrottle.thermal-monitor.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.macthrottle.thermal-monitor</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/mac-throttle-thermal-monitor</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
➜ ~ cat /usr/local/bin/mac-throttle-thermal-monitor
#!/bin/bash
OUTPUT_FILE="/tmp/mac-throttle-thermal-state"
while true; do
THERMAL_OUTPUT=$(powermetrics -s thermal -n 1 -i 1 2>/dev/null | grep -i "Current pressure level")
if echo "$THERMAL_OUTPUT" | grep -qi "sleeping"; then
PRESSURE="sleeping"
elif echo "$THERMAL_OUTPUT" | grep -qi "trapping"; then
PRESSURE="trapping"
elif echo "$THERMAL_OUTPUT" | grep -qi "heavy"; then
PRESSURE="heavy"
elif echo "$THERMAL_OUTPUT" | grep -qi "moderate"; then
PRESSURE="moderate"
elif echo "$THERMAL_OUTPUT" | grep -qi "nominal"; then
PRESSURE="nominal"
else
PRESSURE="unknown"
fi
echo "{\"pressure\":\"$PRESSURE\",\"timestamp\":$(date +%s)}" > "$OUTPUT_FILE"
chmod 644 "$OUTPUT_FILE"
sleep 10
done
The bash script writes the thermal state to a file every few seconds and the app reads it every few seconds!
Using the thermald IPC notifications #
Once I discovered I could use the notification system without elevated privileges, I replaced the helper by code in the app the read the value from the notification system directly. Much simpler 🎉
Temperature and fans #
I wanted to show the temperature and fan speed (when supported) in a little graph in the menu bar app. This would allow me to correlate the thermal state with increased temperature, for example.
Again, there are multiple APIs to read the temperature. First, I started using an undocumented API from IOKit, but I realised I was getting ~80ºC max, while iStat Menus or MX Power Gadget would show >100ºC.
Stats, the open source alternative to iStat Menus, helped me use the SMC instead and get the correct values. But the SMC is a much more unstable API because each SoC has different keys to access the temperature data:
private let m1Keys = ["Tp01", "Tp05", "Tp09", "Tp0D", "Tp0H", "Tp0L", "Tp0P", "Tp0X", "Tp0b"]
private let m2Keys = ["Tp01", "Tp05", "Tp09", "Tp0D", "Tp0X", "Tp0b", "Tp0f", "Tp0j"]
private let m3Keys = ["Tf04", "Tf09", "Tf0A", "Tf0B", "Tf0D", "Tf0E", "Tf44", "Tf49", "Tf4A", "Tf4B"]
Though the M3 keys seem to work on my M4 Max work MacBook Pro…
I ended up using SMC first to get the accurate temperature and fall back to IOKit if SMC doesn’t work.
Graph in the menu bar #
For the graph, I wanted a compact visualization that would show me the thermal history at a glance.
The graph packs three layers of information:
- Colored background segments for each thermal state (green for nominal, yellow for moderate, orange for heavy, red for critical)
- A solid line for CPU temperature with a dynamic Y-axis that adjusts to actual values
- A dashed cyan line for fan speed percentage (on Macs that have fans)
I didn’t want to spend too much time making a super fancy graph system. Since it polls every two seconds, the graph gets very busy after a while. So I decided to keep it down to 10 minutes, since the thermal state history is mostly interesting short-term.
I also added hover tooltips using onContinuousHover.
When the system was under load, I noticed the graph hovering was not very smooth on my 120Hz display. I found out I can add .drawingGroup to my SwiftUI canvas to use GPU rendering!. Indeed, I added it, and it was smooth again.

Adding macOS notifications #
I also added notifications so I get alerted when the state changes, in case I miss the menu bar icon. It can alert on specific state transitions, and optionally on recovery. This is useful to know when it’s time to kill a VS Code instance or a Docker container!

It’s true that I usually already notice when the Mac is getting slow, but sometimes the Mac gets slow when it’s swapping heavily. At least now I know when it’s just too hot.
Launching the app at Login #
Of course, I want the app to start automatically now, since it works so well!
I expected that I would need to write .plist again, but no, it’s extremely easy to prompt the user to add a “login item” as macOS calls it, using SMAppService.
SMAppService.mainApp.register() // enable auto-start
SMAppService.mainApp.unregister() // disable auto-start
SMAppService.mainApp.status == .enabled // check current state
How to use it #
Since I don’t have an Apple Developer account, I can’t notarize the app, so installing it from the releases is going to require a few extra clicks in Privacy and Security.
And for Macs that disallow it entirely, building from source with Xcode is the only way. I added instructions in the README.
Hope this is useful to someone else!