June 22, 2026

Stop Polling the Battery — Listen to It Instead

Replacing a 30-second battery poll with an IOKit run-loop notification: instant reaction, near-zero idle cost, and a small state machine to fire the alert exactly once.

  • Swift
  • macOS
  • IOKit
Illustration comparing a flat polling line to a reactive event spike

The version that worked but was wrong

ChargeBeep’s job sounds trivial: watch the battery, and when it crosses a threshold while charging, play a sound. My first implementation was the obvious one — a timer:

Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { _ in
    self.check()
}

It worked. It was also wrong in both directions at once: too slow to feel instant (you could wait up to 30 seconds after hitting your target before it beeped), and wasteful if you shortened the interval, because now you’re waking the CPU twice a minute to ask a question whose answer almost never changed.

Polling is you repeatedly asking “did anything happen?” The system already knows the answer and is willing to tell you. Ask it to.

Subscribe to power-source changes

IOKit’s power-sources API can hand you a run-loop source that fires a callback the instant the charger is plugged or unplugged, or the percentage changes:

import IOKit.ps

let ctx = Unmanaged.passUnretained(self).toOpaque()
if let src = IOPSNotificationCreateRunLoopSource({ context in
    guard let context = context else { return }
    let me = Unmanaged<AppDelegate>.fromOpaque(context).takeUnretainedValue()
    DispatchQueue.main.async { me.check() }
}, ctx)?.takeRetainedValue() {
    CFRunLoopAddSource(CFRunLoopGetCurrent(), src, .defaultMode)
}

The C-style callback can’t capture Swift context directly, so you pass self through as an opaque pointer and reconstruct it inside — a common bit of choreography when bridging Core Foundation callbacks into a Swift object. Now the app does nothing until power state actually moves, then reacts immediately.

Reading the values is also cleaner than shelling out to pmset and scraping text — you pull them straight off the internal-battery power source:

let cur = d[kIOPSCurrentCapacityKey] as? Int ?? 0
let max = d[kIOPSMaxCapacityKey] as? Int ?? 100
let pct = max > 0 ? Int((Double(cur) / Double(max) * 100).rounded()) : 0

No subprocess, no parsing, no locale surprises.

Keep a cheap fallback anyway

Event-driven code has one failure mode: what if an event never arrives? Rare, but I didn’t want a missed notification to mean a missed alert. So the timer stays — just demoted from primary mechanism to safety net, firing once a minute instead of every few seconds:

fallbackTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
    self?.check()
}

Plus a didWakeNotification observer, because the power source can change while the Mac is asleep and you want to re-check the instant it wakes. Belt, suspenders, and one more belt — but each is nearly free because check() is idempotent.

Fire exactly once: the “armed” flag

Here’s the subtlety that event-driven code introduces. The callback can fire many times around the threshold as the percentage jitters. Naively, “if pct >= threshold, beep” means your laptop chimes at you over and over. You need edge detection, not level detection.

I use a one-line state machine — an armed boolean:

if p.onAC && p.pct < threshold {
    keepAwake(true)
    armed = true                 // below target while plugged in
} else if p.onAC && p.pct >= threshold {
    if armed { playBeep(); armed = false }   // fire once, then disarm
    keepAwake(false)
} else {
    armed = false                // on battery — re-arm for next cycle
}

The beep only fires on the below → reached transition. Once it fires, armed goes false and stays false until you unplug and start a new charge cycle. That single boolean is the whole difference between a polite one-time chime and an alarm clock you can’t turn off.

The shape of the lesson

Going from polling to events isn’t just a performance tweak — it changes the bugs you have. Polling hides state transitions inside “the value is different now than last time I looked.” Events hand you the transition directly, which is faster and cleaner, but now you own the job of not overreacting to a signal that fires more often than the thing you actually care about. A poll wants an interval; an event wants a state machine.