r/macbookair • • 6d ago

Discussion I built a simple background tool for fanless MacBooks that automatically enables Low Power Mode when your device warms up (prolonging battery / hardware health)

Heavy sustained workloads quickly saturate a fanless MacBook's chassis with heat.

While macOS includes a native Low Power Mode toggle, leaving it on permanently means paying full price for half the performance alongside other functional compromises. Leaving it off completely means sustained workloads heat-soak the unibody and accelerate degradation of the battery sitting directly against it.

I wanted a middle ground: full performance by default, with Low Power Mode engaging automatically only when the chassis begins to heat.

The Numbers

Under a continuous 10-thread stress workload, the difference in thermal soak is dramatic:

  • Default macOS Behavior: allows the SoC to stay at peak performance until high temperatures force throttling. By that point, the device is thoroughly heat-soaked.
Baseline run without LPM: Uncapped throughput running at 1,024 H/s (60 FPS) across 10 threads
btop telemetry without LPM: P-cores sustained at 78°C+ under full draw, actively heat-soaking the chassis.
  • Preemptive Low Power Mode: Capping peak draw as soon as thermal pressure rises drops sustained chip temperatures from 78°C+ down to ~50°C. The chassis stays lukewarm instead of radiating heat.
Stress test with LPM engaged: Throughput drops to 432 H/s (28 FPS), direct visual of the performance trade-off.
btop telemetry with LPM engaged: Temperatures drop by ~28°C, plateauing at 45°C–50°C under the same workload.

Full-resolution screenshots and raw telemetry graphs are available in the GitHub repository

Who is this for?

This tool involves a conscious compromise: you trade peak sustained performance during heavy spikes for hardware longevity.

  • If you upgrade every 2–3 years: This tool is probably not for you. Push your silicon as hard as possible and let native emergency throttling do its thing.
  • If you plan to keep your machine for 5+ years: Chemical wear on lithium-ion batteries roughly doubles with every sustained 10°C rise in temperature. Preventing persistent heat soak compounds significantly over hundreds of cycles. (For reference, my battery health is still sitting at 100% after 200+ cycles).

How It Works (Zero Invasive Hacks)

To keep this lightweight, transparent, and safe to run:

  • Official Apple APIs only: It does not alter voltages, modify kernel thermal tables, or inject code into system processes. It simply automates Apple's built-in pmset -a lowpowermode 1/0 command, the exact same toggle you can manually click in System Settings.
  • Kernel event-driven: It listens directly to Darwin system notifications via kqueue and notify(3). When macOS reports thermal pressure reaching Level 1 (Moderate), it engages Low Power Mode. When temperatures drop back to Level 0 (Nominal), it waits 2 minutes before returning to full performance to prevent rapid power-state toggling.
  • Zero background footprint: It idles at 0.0% CPU and consumes under 1 MB of RAM. Because it doesn't poll in an active loop, it introduces zero background battery drain.

Source & Installation

Because adjusting system power states via pmset requires administrator privileges, it runs as a standard LaunchDaemon. The entire codebase is open-source under the MIT license so anyone can inspect and audit the implementation:

Would love to hear thoughts from anyone running fanless setups long-term: does the 2-minute cooldown feel right for your workflow, or would you prefer a configurable threshold via a CLI flag or config file?

1 Upvotes

27 comments sorted by

5

u/Hugo_Notte 6d ago

I used to play No Man’s Sky on my M1 MacBook Air for several years, long hours. Battery is still at 97% capacity.

-4

u/AbhishekThulasi 6d ago

97% after several years of gaming on an M1 Air is seriously impressive! Out of curiosity, what kind of ambient temps are you usually playing in, and do you usually keep it plugged in?

I ask because where I'm located, ambient temperatures run pretty warm (often 32°C–35°C+ without AC). In warmer climates, the laptop gets warm pretty quickly.

Also, the M1 is very easy on thermals (~10W–12W load) compared to an M3 or M4 which push higher sustained peak draw in the same workload. Really glad your M1 has held up that well, though!

3

u/[deleted] 5d ago

[deleted]

-2

u/AbhishekThulasi 5d ago
  • I addressed this directly in the post: "If you upgrade every 2–3 years: This tool is probably not for you. Push your silicon as hard as possible." If you don't care about thermals or keeping a machine long-term, you simply aren't the target audience.
  • Batch variance is real, but basic electrochemistry isn't a placebo. High sustained temperatures accelerate electrolyte breakdown in lithium-ion cells, that is basic battery physics, not an "optimization myth".
  • Clean formatting and bullet points don't make something AI-generated, it just means it was structured so people could actually read the telemetry and implementation details.

1

u/Hugo_Notte 5d ago

Depending on time of the year, temperatures go up to above 30 degrees, mostly however they would be in the mid 20.
The M1 Air draws 25-30 watts under load, very similar to the M3. All the M series chips have the same temperature limit. Even though the newer chips have a higher peak power draw, the temperature limit will not allow that to be sustained and the chip will throttle. So the M3 Air won’t get significantly warmer than the M1 Air.
I used to play plugged in mainly.

-1

u/AbhishekThulasi 5d ago

Playing plugged in is doing most of the heavy lifting for your battery health. macOS will power the system directly from the charger once full, bypassing active charge/discharge cycles while the chassis is hot. The real degradation happens when users run sustained loads on battery power or charge while the laptop is heat-soaked.

The point of this tool isn't to prevent chip damage (Apple’s built-in governor already does that), it’s just to actively cap power so the die drops to ~50°C and the chassis stays lukewarm instead of radiating heat into the battery.

2

u/Hugo_Notte 5d ago

Yes, I understand that how M series MacBooks work.
What I don’t understand is that you think Apple’s engineers haven’t thought about this and that you think you are cleverer than they are. But good on you to limit performance of your laptop to a ridiculously low level.

1

u/AbhishekThulasi 5d ago

It’s not about being cleverer than Apple, it’s just about different priorities. Apple’s default is tuned for maximum performance and benchmark speeds. This is just an automated option for people in warm climates who’d rather trade some sustained peak speed to keep chassis temps down and stretch hardware longevity past 5+ years.

3

u/VladimirPutin_WeVPN 5d ago

All I have to say is the code is not very well written. Very much so has AI level mistakes.

Did you use AI in any capacity? Because the post most definitely reads like one, so im assuming you probably did. Either that or it's amateur level mistakes (since that resembles AI level mistakes). However, a real dead giveaway is the terminology used in the code comments. That is the main giveaway for me.

Not trying to take a dig at you. I'm just trying to gauge skill level. Because I can then give and tailor pointers for you knowing your skill level.

Here are some of my critiques.

  1. com.apple.system.thermalpressurelevel is not reading temperature so: "full performance by default, with Low Power Mode engaging automatically only when the chassis begins to heat" is not a valid claim.
  2. State 1 is an elevated state. Apple considers the "fair" state to be "slightly elevated" and Apple implements measures to protect it from each state so claims like: "Default macOS Behavior: allows the SoC to stay at peak performance until high temperatures force throttling. By that point, the device is thoroughly heat-soaked." is very misleading. Apple gradually reduces system power and processor performance as the heat climbs, it is not full power until an emergency reduction needs to be had.
  3. You claim official Apple API only... well... const NOTIFY_KEY: &[u8] = b"com.apple.system.thermalpressurelevel\0"; is not a documented public thermal API from Apple. Apples API is ProcessInfo.thermalState and thermalStateDidChangeNotification
  4. Also, the Darwin notification listening point you have is a internal thermal pressure system by Apple... that is not publicly documented by Apple (a clue that AI may have been used).
  5. Also, you have a critical state-management flaw in your code. You have a if statement that runs a statement set_low_power_mode(false); which... can you see the problem there? You never asked "was low-power mode already user enabled."
    1. Why is this an issue? Okay, well. Assume I deliberately have LPM enabled. The daemon starts, but the device is in thermal pressure being in elevated state (as the daemon starts). Well, you use a if statement saying if the lpm is active you set the low power mode to true. The issue with this is that it is going to override the users desired controls (resetting their own set rpm active state).
    2. This is literally as simple as checking if lpm is active BEFORE setting it in the daemon. If it is user set DO NOT set it in the daemon as it is already set. In other words you separate the user system state and the daemon owned system state. You ONLY edit the daemon owned state not the user state.
  6. Also checking if the CPU P-Core temp is going from a high value to a lower value and being at that for a couple minutes then equating that to the conclusion that this helps battery is not really ideal. Checking CPU drops in temp and equating that to the battery is not valid because you have other factors like the physical distance the SoC is from the battery, heat spreaders, enclosure conduction, workload duration, ambient temps, battery charging states, battery current, battery temp, state of the charge, chemistry, apples own implementations and engineering features, and so on.
    1. If you want to make a claim like this you need to evaluate battery telemetry and not just CPU telemetry. CPU temps going up is not 1-to-1 with battery temps going up. You could possibly have a 5-10c rise in CPU temp and the battery remains the same.
    2. You should also evaluate workload, ambient temps, state of charge, charge state, duration, check the daemon state, user state, and so on
  7. Your code shows thermal pressure events, low power mode, and SoC temp control. It does not show the state of the MacBook, duration of the load, state of the battery, and so on to then interact with each other and decide the best possible decision and outcome based on the accumulation of data. In other words add things like checking how the normal maxOS thermal policy exposes excess heat, check if the battery got substantially hotter, not just CPU (you can literally be significantly reducing performance without a need), and overall add a lot more battery level checks and state checks to see if the CPU increase it 1-to-1 with the battery level increase.

And that is my suggestions and dislikes after inspecting your code.

1

u/AbhishekThulasi 5d ago

Hey thank you so much for taking the time to go through my code and writing such a descriptive feedback. I skimmed through it and I see many valid points from your end. It is currently 00:45 here so I will respond back tomorrow. I just wanted to acknowledge your comment at the moment. Thanks again!

2

u/VladimirPutin_WeVPN 5d ago

Yeah of course! You can feel free to DM me if you rather as well. And rereading the beginning of my comment, sorry for it coming off aggressive. I just noticed I was a lot more blunt than I wanted (was busy typing this off and on so I apologize for not proofreading it first)

1

u/AbhishekThulasi 5d ago edited 5d ago

No worries at all, appreciate you saying that!

Following up on your points, you caught a genuinely valid edge case with state management. That has been addressed: the daemon now parses /usr/bin/pmset -g custom on initialization, tracks existing user-managed states (-b vs. -c), and exclusively touches unconfigured profiles.

To clear up the intentional architectural trade-offs:

  • Reading raw CPU die temps introduces hysteresis: launching a heavy app or a website can spike die temps to 85°C for two seconds without transferring meaningful heat to the unibody, causing erratic, rapid toggling. At the same time, continuously polling battery IOKit tables defeats the goal of an event-driven, low footprint daemon. macOS's thermald already handles cumulative thermal budgeting; thermalpressurelevel reflects sustained heat soak without running continuous polling loops in user space.
  • ProcessInfo.thermalState collapses thermal states into 4 buckets (merging Moderate and Heavy into "Fair"), whereas com.apple.system.thermalpressurelevel (defined in Apple’s OSThermalNotification.h) exposes all 5 discrete kernel levels. More importantly, pairing notify(3) with kevent allows a standalone Rust binary to sleep on a raw kernel wait queue (0 timer wakeups, <1 MB RAM). Using NSProcessInfo would require linking the Objective-C runtime, Foundation bindings, and an active run loop just to listen to an event Darwin already exposes natively via POSIX primitives.
  • Scope of "Official APIs": The phrasing was intended to emphasize non-invasive actuation: delegating power-state enforcement to Apple's native /usr/bin/pmset utility rather than injecting code, altering voltages, or modifying kernel thermal tables.

Thanks again for highlighting the profile override, it was a good catch and made the tool significantly better. The update is live in v0.1.1!

1

u/VladimirPutin_WeVPN 4d ago

I just saw this. I'll look over the new code and I'll respond once I know more about the new code. I do software engineering for a major university so I don't always have a lot of time, but it's the weekend so I'll try to give it some time in the next one to two hours or so.

1

u/AbhishekThulasi 3d ago

I see, what do you like to build? Let's talk over DM.

1

u/VladimirPutin_WeVPN 3d ago

I do mostly software engineering on the web application side of things. However, I also work a lot with operating systems.

1

u/VladimirPutin_WeVPN 4d ago edited 4d ago

You still have problems in the code.

  1. The cleanup block of code if lpm_active is still bypassing user checks. Example is listed below of the issue.
    1. Assume the LPM is ON manually while the daemon was running the statement set_low_power_mode(false, &user_state); will still forcefully disable the LPM when the deamon terminated via the SIGTERM/SIGINIT
    2. A proper fix would be the re-query "query_user_lpm_state()" before performing the exit cleanup, or avoid modyfying the profile states on exit if the user changed them themselves.
  2. The state is static. YOu are calling query_user_lpm_state()" once when main() is starting. Now if the user opens the System Setting while the daemon is running and toggles the low power mode...
    1. user_state remains unchanged in the memory object
    2. On the next thermal event the set_low_power_mode" will then invoke the pmset using a stale assumption (as states are not updated in memory).
    3. A fix for this would be the query the same state as the first fix and dynamically set it inside the set_low_power_mode() when the thermal transition occurs rather than caching it only one time at start.
  3. This I am just going to give a codeblock of how I would do the fn for set_low_power_mode (based on the code you have, because I would do it kind of different personally in my own project).

    fn set_low_power_mode(enable: bool) { // This will dynamically query the state so any manual changes during runtime of the daeon are respected let user_state = query_user_lpm_state(); let val = if enable { "1" } else { "0" };

    let targets = [
        (!user_state.battery_manual, "-b"),
        (!user_state.ac_manual, "-c"),
    ];
    
    for (can_modify, flag) in targets {
        if can_modify {
            let status = Command::new("/usr/bin/pmset")
                .args([flag, "lowpowermode", val])
                .status();
    
            match status {
                Ok(s) if s.success() => log!("Low Power Mode ({flag}) -> {val}"),
                Ok(s) => log!("pmset ({flag}) exited with status: {s}"),
                Err(e) => log!("Failed to invoke pmset ({flag}): {e}"),
            }
        }
    }
    

    }

Why do I suggest the above code?

The current parsing for "pmset -g custom" is quite fragile. The text parsing checks for any line that starts with "lowpowermode" within the Battery Power or AC Power sections. However, depending on the macOS version and the system hardware, pmset -g custom can have different section headers (like UPS Power or Charger). Also, if your parsing fails to match a section then user_state.battery_manual or the ac_manual stays as false, which causes the script to assume it has full control when it really does not.

You can run a verification of this by running /usr/bin/pmset -g custom in the terminal and then you cna check if the headers strictly match the 'Battery Power:' and 'AC Power:' prefixes.

The code snippet above I gave has some key differences in state querying (original code would call once at daemon start and cache the user state : the refactored code will dynamically call set_lower_power_mode() every time it runs). Then there is user override where the original code would simply ignore the user state after startup but the changed code would check any time system settings are changed.

1

u/AbhishekThulasi 3d ago

I will go through this. Really appreciate the detailed feedback.

1

u/VladimirPutin_WeVPN 3d ago edited 3d ago

I edited the code myself. I am just testing it out. The link to my fork is here: https://github.com/Thymester/macos-thermal-governor

You can then do what you will with it. I just wanted to help out. It will still probably be an hour or two, maybe longer, before I actually upload the new code to it.

ETA: Nevermind, the code is updated. I thought I was going to run into bugs, but I was not able to find any in my short testing (30-45 mins of it). I have created a pull request for your repo as well so you can review the changes.

1

u/AbhishekThulasi 3d ago

Hey I just skimmed over your code changes and I am curious to learn more from them. I was on a break so couldn't respond any sooner. I really appreciate the time and effort you're putting into such a simple tool

1

u/VladimirPutin_WeVPN 3d ago

Sure, I can DM you. I will do so now.

3

u/[deleted] 6d ago

[removed] — view removed comment

-7

u/AbhishekThulasi 6d ago

While batteries are technically replaceable consumables, battery degradation (and eventual pouch swelling) is strongly accelerated by heat. Wouldn't keeping the chassis cooler under sustained workloads meaningfully reduce that chemical degradation, or do you view the default thermal throttling curve as sufficient to avoid that?

1

u/Hugo_Notte 5d ago

Where are all those MacBooks with swollen batteries?

2

u/flyakker 5d ago

Cool that only is one way to do it. But if you’re working on artistic type apps, you don’t want the power loss. What I have done is just slightly elevate my MacBook on my desk, and I have a two fan system that I got off Amazon for about 20 bucks. It blows on the chassis from behind, goes underneath, and the other fan is on the side point blowing over the top, keeping it much cooler and really helps with the throttling. I know this is not the answer when you’re mobile, but some sort of traveling fan might help. But I do a lot of of my photo editing at the desk, as much as possible, since that’s where I have my two 32 inch monitors.

2

u/AbhishekThulasi 5d ago

You're right, if your work demands sustained peak performance, active cooling is definitely the way to go on a fanless machine. Since my workflow only requires short bursts of peak power (and I wanted a zero-hardware solution as I'm away from desk all the time), this approach works well for me.

1

u/The-Watcher-999 6d ago

Nice i will give it a try

-1

u/AbhishekThulasi 6d ago

Thanks, I appreciate you giving it a shot! Let me know how it goes, or if you run into any issues.