A user wrote in: “The app makes my phone hot even when I’m not doing anything.”
The app in question is a photo-cleanup tool. When it’s actively doing something — scanning a 26K-image library — it’s CPU-heavy and the device warms up. Expected. But this complaint was about the idle state. App in foreground, no scan running, no animations playing, no user interaction. Phone still gets hot.
Two things bother me about a report like that. First, the user is correct — phones don’t get hot for free, so something is happening. Second, our profiling tools all show “low activity” because no single thing is using meaningful CPU. The problem is rarely a hot loop; it’s usually the sum of small things.
This is the story of finding four such small things, what we replaced them with, and why polling is almost never the right default in a battery-bound runtime.
What the symptoms looked like
The thermal escalation pattern was specific:
- App open ~3 minutes: warm but normal
- App open ~10 minutes: noticeably warm to the touch
- App open ~20+ minutes: iOS thermal API reports
seriousorcritical, the device starts throttling
Throttling is the giveaway. iOS doesn’t throttle randomly — it does it when it can’t shed the heat being generated. So something in our app was generating sustained heat even at rest.
The instinct is to reach for Instruments and look at the Time Profiler. We did. The profile told us “low CPU, low GPU, low energy impact.” Which was misleading — not wrong exactly, but it averages too aggressively. The profile aggregates per second. Our problem aggregated per minute.
What we found
The codebase had four always-on polling loops, each individually trivial, that collectively prevented the CPU from ever truly going idle.
| System | Interval | Per tick |
|---|---|---|
useStorageHealth | 60s | Native call to iOS getStorageInfo() |
useStorageLevel | 60s | Second native call to storage subsystem |
LimitsContext | 60s | Two AsyncStorage reads |
TelemetryService | 60s | Drain queue + React state update |
A minute apart, none of these is a big deal. The problem is they’re not actually a minute apart. They started at roughly the same time when the app launched, so they fire within milliseconds of each other, once a minute, forever. Each tick wakes the JavaScript thread, runs four bits of work, and updates state. State updates trigger React re-renders. Re-renders trigger React Query cache management. The cache management settles. The thread parks. ~60 seconds later, the cycle repeats.
In iOS terms, this means the CPU’s deep-sleep state can’t be entered for any meaningful duration. The runtime stays in a shallow sleep, the SoC’s leakage current stays elevated, heat accumulates faster than the chassis can dissipate it, and after 20 minutes the device starts throttling. The actual work the app does is tiny.
The cost isn’t in any one operation. It’s in the absence of true idle.
It’s like leaving your car’s engine running while parked, idling at 800 RPM: the engine isn’t doing anything useful, but it’s still burning fuel and generating heat.
Why polling intervals tend to accumulate
Looking at how the intervals got there, there’s a pattern that’s almost universal in long-lived React Native codebases:
- A feature ships with no polling (event-driven, e.g. on user action).
- An edge case appears: “what if the user doesn’t touch this for an hour and the data goes stale?”
- Someone adds a polling interval as a defensive freshness guarantee — typically with a comfortably long interval, 30s or 60s.
- The interval is unconditional because writing the conditional is harder than writing the unconditional version, and the cost feels negligible.
- Six months later, four other features have done the same. The aggregate is a non-trivial idle CPU baseline that no individual engineer is responsible for.
Each individual addition is defensible. The combined effect isn’t.
What we replaced them with
The fix is conceptually simple — for each interval, ask: what is this actually trying to catch? — and replace it with something event-driven.
Storage health: foreground + operation-triggered
The two storage hooks were polling for “did the disk fill up while the app was open?” The answer is: the only ways disk usage changes meaningfully while our app is open are (a) we just deleted photos, (b) we just completed a backup, (c) we just converted Live Photos. All of those are events we already emit. So:
- refetchInterval: 60_000,
- refetchOnWindowFocus: false,
+ // No interval — refresh on foreground + after storage-affecting ops.
+ refetchOnWindowFocus: true,
Then, in each place where storage state changes, we explicitly invalidate the React Query cache:
// LibraryActionsContext.tsx — after delete
queryClient.invalidateQueries({ queryKey: queryKeys.storage.health() });
queryClient.invalidateQueries({ queryKey: queryKeys.storage.level() });
Five files got two-line edits — the cache invalidation calls. Now the storage UI updates exactly when storage actually changes, plus the cheap foreground-refetch fallback for the long-tail “user switched apps for an hour” case. The polling is gone, and the data is more fresh in the cases that matter — deletion now invalidates the cache in milliseconds instead of waiting up to 60s for the next poll.
Limits context: drop the interval entirely
The limits context was polling once a minute primarily to catch the daily reset rollover. “Did we cross midnight? Update the UI to show the fresh daily limit.”
The cost-benefit was indefensible. We were paying a 60-second wake-up cost from app launch to app close, to handle the edge case where the user keeps the app foregrounded across midnight without ever touching it. The fix:
- // Set up periodic refresh (every minute to catch reset)
- refreshIntervalRef.current = setInterval(() => {
- refreshLimits();
- }, 60_000);
+ // No interval. We rely on:
+ // - AppState foreground refresh (already existed)
+ // - Refresh after consumeDaily / consumeLifetime
+ // - limitSyncService subscription for backend-driven changes
And the midnight-rollover concern is genuinely a non-issue: actual limit enforcement happens in the service layer, which always reads fresh data from the source of truth before allowing an action. The interval was only ever updating the visible limit counter for cosmetic accuracy. In practice, if a user crosses midnight with the app open and idle, they’ll see the previous day’s counter for a moment until they tap anything — at which point the action triggers a refresh and the UI catches up. We accept that.
Telemetry: already conditional, keep as-is
The telemetry service was the only one of the four that was conditional. It would only fire its 60s drain if the queue had items. Idle apps generate no telemetry events, so the queue is empty, so the interval never does work. Functionally fine, no change.
Worth flagging because conditional polling is the right pattern for the cases where polling is genuinely necessary — the interval exists, but only does work when there’s work to do.
What’s left running, and on what condition
The remaining intervals in the codebase are all conditional, gated on a specific user-visible activity:
| System | Runs when | Interval |
|---|---|---|
TelemetryService | telemetry queue is non-empty | 60s |
SmartAnalysisContext | a scan is actively running | 30s stuck-check watchdog |
TipsCarousel | dashboard is focused | 30s rotation |
Dashboard overview | a scan is running | 5s progress refresh |
BackgroundScanBanner | a smart analysis is running | 2s banner pulse |
The pattern: every interval has a guard that returns to true idle the moment the corresponding feature is dormant.
And the ones we verified are not polling at all, despite appearances:
PhotoLibraryObserver— usesPHPhotoLibrary.shared().register(observer), event-drivenICloudNetworkMonitor— usesNWPathMonitor, event-drivenSkiaSplashScreen— unmounts cleanly after the startup sequence finishes- Various photo-background components — gated on
isFocused
The before / after, measurably
The user that reported the issue tested the fix on their device for 30 minutes idle and reported it stayed cool the whole time.
The next time you get a bug report that the app “makes the phone hot when not doing anything”, the first thing to look at is the inventory of intervals. Count them. Add up the wake-ups per minute. If that number is anywhere above zero in a state where the user isn’t interacting, you’ve found the leak.