Reducing Flag SDK Battery and Network Cost
This how-to is part of Mobile and Native Flag Delivery. Your flag SDK works, but the battery team flagged it: it wakes the radio too often and downloads the whole flag catalog on every refresh. This how-to trims that cost — coalescing refreshes, gating on power and connection state, batching and compressing the payload, and backing off on failure — so flags stay fresh without showing up in the battery report.
The reason a flag SDK shows up in energy audits at all is rarely the bytes it moves — a flag payload is small next to an image or a video segment. It is the radio state transition. On a mobile network the modem climbs from idle to a high-power connected state, holds there for a tail of five to ten seconds after the last byte, then steps back down; every wakeup pays that full tail whether it transferred two kilobytes or two hundred. Ten scattered refreshes over a minute keep the radio hot the entire minute. Coalescing those into one keeps it hot once. That is why every lever below is really a lever on how often and under what conditions you touch the radio, not on payload size alone — and why the profiler improvement is so much larger than the byte-count improvement would suggest.
Prerequisites
Step-by-Step Procedure
Step 1 — Coalesce refresh triggers
Collapse a burst of triggers (foreground, screen change, manual) into a single refresh with a minimum interval, so ten events in a minute cause one fetch, not ten. The two guards do different jobs: the MIN_INTERVAL_MS check throttles across time — a second trigger inside the window is dropped entirely and returns the resolved promise — while the inFlight check deduplicates in the moment, so two triggers that fire in the same tick share one network call instead of racing. Pick the interval from your freshness budget, not a round number: if the product can tolerate a flag being up to two minutes stale, MIN_INTERVAL_MS of 120000 is correct and anything shorter just burns radio for freshness nobody asked for. Note that this collapses the interval to a fixed floor — it deliberately does not debounce a trailing refresh after the window, because on mobile a slightly stale value is almost always cheaper than an extra wakeup.
// coalesce.ts — one refresh per interval regardless of trigger count
let lastRefresh = 0, inFlight: Promise<void> | null = null;
export function requestRefresh(): Promise<void> {
if (Date.now() - lastRefresh < MIN_INTERVAL_MS) return Promise.resolve();
if (inFlight) return inFlight; // dedupe concurrent triggers
inFlight = doFetch().finally(() => { lastRefresh = Date.now(); inFlight = null; });
return inFlight;
}
Step 2 — Gate on battery and connection state
Skip non-critical refreshes when the battery is low and unplugged or the connection is metered, deferring until conditions improve. The critical short-circuit at the top is the load-bearing line — it is what keeps the gate from ever standing between an emergency toggle and the device, which is the single most dangerous failure this whole page can introduce (more on that in the gotchas). The thresholds are deliberately conservative: 0.15 unplugged is well above the level where the OS starts its own aggressive throttling, so you defer before the platform makes the decision for you and your refresh gets killed mid-flight. Read connection.isMetered rather than inferring from connection.type alone — a user on an unmetered corporate cellular plan or a metered home hotspot both break the naive “cellular means expensive” assumption, and the OS-reported metered flag is the signal that actually matches the user’s cost. When a refresh is gated out, drop it rather than queueing it; the next foreground event will supply a fresh trigger, so a queue only risks a stale burst firing the instant power returns.
// gate.ts
function shouldRefresh(critical: boolean): boolean {
if (critical) return true; // kill-switch class always refreshes
if (battery.level < 0.15 && !battery.charging) return false;
if (connection.type === 'cellular' && connection.isMetered) return false;
return true;
}
Step 3 — Batch and compress the payload, with ETag
Fetch the entire flag set in one gzip-compressed request and send the stored ETag so an unchanged set returns a 304 with no body. Batching the whole set into one request matters more than it looks: per-flag fetches multiply the radio tail by the flag count, and each request carries its own TLS and header overhead that can dwarf the flag data itself. One request amortizes all of that once. The ETag path is where most of the savings live in steady state — a device that opens the app fifty times between flag changes should pay for the payload once and get forty-nine cheap 304 Not Modified responses, each a few hundred header bytes with no body and no JSON parse. Persist the ETag next to the cached flags, not in volatile memory, so it survives process death and the first refresh after a cold start can still short-circuit. One caution: honour the 304 by keeping your existing cache untouched — a common bug is to treat any non-200 as “no data” and blow away good flags on a response that specifically means “your data is still correct.”
// fetch.ts
async function doFetch() {
const res = await fetch(FLAGS_URL, {
headers: { 'If-None-Match': storedEtag ?? '', 'Accept-Encoding': 'gzip' },
});
if (res.status === 304) return; // unchanged — near-zero cost
storedEtag = res.headers.get('ETag') ?? storedEtag;
await persist(await res.json()); // one request for all flags
}
Step 4 — Back off on failure
On a failed refresh, back off exponentially with jitter instead of retrying immediately, so a flaky connection does not become a wakeup storm. This mirrors exponential backoff for SDK reconnection. The jitter term — the (0.5 + Math.random()) multiplier — is not cosmetic: without it, a whole fleet that lost connectivity together will retry together the instant the network returns, and your backend takes the full synchronized wave at each doubling. The cap (5 * 60_000, five minutes) bounds the worst case so a device that has been offline for hours does not compute an absurd delay and effectively stop retrying. Resetting attempt = 0 on success is what makes the next genuine failure start cheap again instead of inheriting an inflated delay. Treat a 4xx differently from a 5xx or a transport error if you can — a 401 or 403 will fail identically on every retry, so backing off is pointless there; that class wants a token refresh or a hard stop, not patience.
// backoff.ts
let attempt = 0;
async function refreshWithBackoff() {
try { await doFetch(); attempt = 0; }
catch {
attempt++;
const delay = Math.min(2 ** attempt * 1000, 5 * 60_000) * (0.5 + Math.random());
scheduleAfter(delay, refreshWithBackoff); // jittered, capped
}
}
Verification
Profile before and after: count network calls and radio wakeups over a fixed session, and confirm an unchanged flag set returns a 304. Measure a scripted, repeatable session rather than a hand-driven one — the same screens in the same order — or your before-and-after numbers are noise. On Android, dumpsys batterystats attributes wakelocks and mobile-radio active time to your package, and the number to watch is not raw request count but radio-active seconds, because that is what the tail actually costs; on iOS, the Energy Log instrument in Instruments gives the equivalent per-process view. The curl -I check confirms the server side of the ETag contract in isolation, separate from the SDK: if that returns 200 with a body when you pass a known-current ETag, the problem is server configuration, not your client. Expect the after run to show most refreshes as 304s and the radio-active time to fall by a larger fraction than the request count — that gap is the coalescing and gating doing their job.
# Count flag refresh requests over a session and check for 304s
adb shell dumpsys batterystats | grep -i "flag\|wakelock" | head
curl -sI "$FLAGS_URL" -H "If-None-Match: $(cat etag.txt)" # expect: HTTP/1.1 304 Not Modified
Gotchas & Edge Cases
- Do not gate critical flags. A battery or metered gate that also blocks the kill-switch refresh means an emergency flag change never reaches the device. Exempt a small “critical” flag class from the gates so it always refreshes. The failure is silent and only surfaces during the incident you built the kill switch for — precisely when you cannot afford to discover it — so treat the exemption as a correctness requirement, not an optimization.
- Synchronized refresh hammers the backend. If every device refreshes at the same wall-clock moment, your backend sees a thundering herd. Add jitter to any time-based trigger and lean on the naturally-staggered foreground events. The worst offender is a push-triggered “flags changed” fanout: pushing to a million devices at once tells all of them to refresh in the same second, so smear the wake with a random delay proportional to fleet size before the fetch. See distributed caching.
- The OS may suspend background work. Do not rely on background timers for refresh; the platform can suspend or kill them to save power. Tie refresh to foreground activity, which is guaranteed to run when the user is actually there. Background execution budgets tighten with every OS release and vary by device vendor, so a background timer that works on your test phone may never fire on a user’s aggressively power-managed handset.
- The coalesce clock can drift on cold start.
lastRefreshinitialized to0means the very first request after launch always passes the interval check, which is usually what you want — but if you persistlastRefreshacross launches to avoid a fetch on every cold start, make sure you also validate the cached flags are not already stale beyond your tolerance, or a quick relaunch can serve flags from an interval ago while suppressing the refresh that would fix them. - Compression can leak through response size. If your flag payload contains user-specific or sensitive values and rides the same connection as reflected input, gzip’s size-based compression can theoretically expose a side channel. It rarely matters for a flag catalog, but keep genuinely sensitive per-user values out of the bulk payload and resolve them through a separate authenticated path rather than compressing them alongside public flag definitions.
Troubleshooting & FAQ
Flags feel stale after these optimizations. Did we go too far?
Possibly — check whether the minimum coalesce interval is longer than users’ typical session gap, so they never trigger a refresh. Shorten it for the flags that matter, or add a manual refresh for freshness-sensitive screens. Most staleness complaints trace to the gate or interval being too aggressive for the critical class.
Does compression actually matter for a small flag payload?
For a handful of flags, marginally; for a catalog of hundreds it is significant over metered data and at fleet scale. The bigger win is usually the ETag 304 path, which eliminates the body entirely for unchanged sets. Enable both — they compose.
Should critical flags use a separate, more frequent refresh?
Yes. Split the flag set into a small critical class (kill switches, high-risk toggles) refreshed more eagerly and exempt from the gates, and a bulk class refreshed on the normal foreground cadence. This keeps the urgent flags fresh without paying that cost for every cosmetic toggle.
Should I use streaming instead of polling to save battery?
It depends on how long sessions last. A persistent streaming connection avoids repeated wakeups when a session is long and flags change often, but holding an idle socket open keeps the radio from fully sleeping and can cost more on a device that opens the app in short bursts. For most mobile apps — short, frequent foreground sessions — coalesced polling with ETag 304s is cheaper than a stream, because a backgrounded app should not be holding a socket at all. Reserve streaming for foreground-heavy, long-lived sessions where sub-second flag propagation genuinely matters.
Where should the coalesce interval and gate thresholds live — in code or in a flag?
Put them behind a flag. The whole point of a flag system is that you can retune it without shipping a build, and the refresh policy is exactly the kind of value you will want to adjust once real fleet telemetry arrives. Deliver the coalesce interval, battery threshold, and metered behaviour as a small self-referential config flag that the SDK reads on each pass — just make that config part of the critical class so a bad value can always be corrected remotely.
Does an ETag 304 still wake the radio?
Yes — a 304 is a full request-response round trip, so it pays the same radio transition and tail as a 200; it only saves the body bytes and the parse. That is exactly why coalescing and gating come first: they cut the number of round trips, while the ETag makes each unavoidable trip as small as possible. Do not treat 304s as free and let their count creep up, or you will have optimized payload size while leaving the expensive part — the wakeup frequency — untouched.