Our phone was getting hot from doing nothing

Users said the app made their phone hot when they weren't even using it. The forensics turned up four always-on polling intervals stacking into a permanent CPU baseline. The fix, the patterns that replaced them, and why polling is the wrong default.

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 serious or critical, 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.

Four 60-second polling intervals stack into a continuous CPU baseline Top panel: four polling intervals (storage health, storage level, limits context, telemetry) firing in lockstep every 60 seconds, producing a synchronised wake-up that prevents the CPU from going idle. Bottom panel: the same period after the fix — empty except for two short events triggered by user interaction. BEFORE four unconditional 60-second intervals CPU never idle 0s 60s 120s 180s useStorageHealth useStorageLevel LimitsContext TelemetryService AFTER event-driven + foreground refetch CPU genuinely idle 0s 60s 120s 180s All systems foreground refresh user deletes a photo — quiet for 75s — — quiet for 105s —
Top: four 60-second polling intervals firing in lockstep every minute (the red bands show the wake-up clusters where all four collide). Bottom: same period after the fix — flat baseline broken only by two events the user actually triggered.

What we found

The codebase had four always-on polling loops, each individually trivial, that collectively prevented the CPU from ever truly going idle.

SystemIntervalPer tick
useStorageHealth60sNative call to iOS getStorageInfo()
useStorageLevel60sSecond native call to storage subsystem
LimitsContext60sTwo AsyncStorage reads
TelemetryService60sDrain 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:

  1. A feature ships with no polling (event-driven, e.g. on user action).
  2. An edge case appears: “what if the user doesn’t touch this for an hour and the data goes stale?”
  3. Someone adds a polling interval as a defensive freshness guarantee — typically with a comfortably long interval, 30s or 60s.
  4. The interval is unconditional because writing the conditional is harder than writing the unconditional version, and the cost feels negligible.
  5. 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:

SystemRuns whenInterval
TelemetryServicetelemetry queue is non-empty60s
SmartAnalysisContexta scan is actively running30s stuck-check watchdog
TipsCarouseldashboard is focused30s rotation
Dashboard overviewa scan is running5s progress refresh
BackgroundScanBannera smart analysis is running2s 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 — uses PHPhotoLibrary.shared().register(observer), event-driven
  • ICloudNetworkMonitor — uses NWPathMonitor, event-driven
  • SkiaSplashScreen — unmounts cleanly after the startup sequence finishes
  • Various photo-background components — gated on isFocused

The before / after, measurably

1–2 → 0
native bridge calls
per minute, idle
4–5 → 0
state updates
per minute, idle
~20 min → ∞
time to thermal `serious`
not reached after fix

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.

← Back to blog