●DEADLINE — Two days remain until Google Play requires Android 16 (API level 36). From August 31 it applies to new apps and to updates of existing ones alike●EXTENSION — The extension that keeps you shipping to all users until November 1 cannot be filed after the deadline passes, so the decision point is effectively today●CHECK — What matters is not the targetSdkVersion number itself but whether a build at that level still compiles and reaches submission. Run it end to end once●EXPO — expo@57.0.17 shipped on August 27, moving React Native to 0.86.3 and clearing both the Hermes V1 memory regression and the startup time regression●PREBUILD — expo prebuild clears and regenerates the native android and ios directories by default, so move hand-edited native changes into a config plugin first●RORK MAX — Rork Max generates native Swift, reaching AR and LiDAR, Dynamic Island, Live Activities, HealthKit, and Core ML — layers React Native struggles to touch●DEADLINE — Two days remain until Google Play requires Android 16 (API level 36). From August 31 it applies to new apps and to updates of existing ones alike●EXTENSION — The extension that keeps you shipping to all users until November 1 cannot be filed after the deadline passes, so the decision point is effectively today●CHECK — What matters is not the targetSdkVersion number itself but whether a build at that level still compiles and reaches submission. Run it end to end once●EXPO — expo@57.0.17 shipped on August 27, moving React Native to 0.86.3 and clearing both the Hermes V1 memory regression and the startup time regression●PREBUILD — expo prebuild clears and regenerates the native android and ios directories by default, so move hand-edited native changes into a config plugin first●RORK MAX — Rork Max generates native Swift, reaching AR and LiDAR, Dynamic Island, Live Activities, HealthKit, and Core ML — layers React Native struggles to touch
The Remote Config keys you add after your release path closes are invisible to the build already on devices
A record of recounting what I can still change remotely before my release path closes. A key you add in the console does nothing unless a build that reads it is already on devices. Which switches to ship in the last build, and a shared-defaults bug I measured.
With August 31 approaching, I lined up my apps and went through them one at a time, asking whether each one could realistically get a new build out before the deadline. When you have been shipping as an indie developer for long enough, a few apps always end up with a slow update cadence. They still work, but you have not touched them recently — and those are exactly the ones where a build attempt turns into a dependency archaeology session.
So I changed the question. Suppose I miss the deadline on one of them and end up unable to ship a new build for a while. How much of that app can I still change from the outside?
Counting it up, the answer was narrower than I had assumed. The reason is simple: the only things you can move remotely are the branches the shipped build was already written to read. Obvious in hindsight, but when you always have a release path available, you never have to think about where that boundary sits.
Operational actions are a different story. Disabling a feature, showing a notice, raising the minimum supported version — all of those route through remote configuration. That is the part this article is about.
A key in the console does not exist unless a build that reads it is out there
Once you have remote config wired up, you start to feel like anything can be changed later. In practice, the only things you can change are the names a shipped binary calls getString("...") on. Add a new parameter in the console and nobody reads it, because no build on any device references it.
Normally this constraint never surfaces. When you want a new key, you ship the code that reads it in the same release. As long as key and code travel together, there is no gap.
The gap only bites when the release path closes. If you decide mid-freeze that a feature needs to be turned off, and the shipped build has no branch for turning it off, there is nothing the console can do for you.
Which means the last release before a freeze is not only about the features you are shipping. It is also the release where you plant the controls you might need for the months that follow. Adding switches you have no immediate use for is normally a habit worth resisting. When a freeze is on the table, the opposite judgment is the correct one.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You'll be able to ship the switches you might need during a freeze in the last build that goes out before it starts
✦You'll be able to drop in a single script that audits Remote Config keys against the shipped tag rather than your working tree
✦You'll be able to estimate the real delay between saving a value and it taking effect, given the 12-hour default fetch interval
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Anything below this gets a notice. It also lays the groundwork for cutting old builds loose once the freeze lifts. Version-string comparison behaves inconsistently across implementations, so I standardized on integer build numbers. The traps in string comparison are measured in When 1.10.0 Gets Locked Out: Measuring Four Version Comparison Approaches for Forced Updates.
3. Asset delivery base URL
Being able to swap the origin absorbs CDN incidents and path restructures without an app update. I only accept strings that start with https://.
4. Notice copy
A slot for "we're aware of an issue" or "a fix is on the way." It is the switch most likely to matter during a freeze and the one most likely to be forgotten.
Typed keys versus a single payload
Conventional advice favors individually typed parameters. They read clearly in the console and the type is enforced there.
Under a freeze, that shape works against you. When a new angle comes up, you cannot add a new key that anyone will read. So I folded all four into one JSON string behind a single key and made the app parse it loosely. The idea is to keep the expressive power inside the build that is already on devices.
Dimension
Typed keys
Single payload
Console readability
High — each line has meaning
Low — you read JSON
Expressiveness during a freeze
Low — new keys never arrive
High — recombine within the existing shape
Where validation lives
Partly in the console
Entirely in the app
Blast radius of a bad value
That one key
Everything falls back to defaults
Best suited to
Normal release cadence
Periods where releases may stop
That last drawback turns straight into a requirement. If you fold everything into one payload, the parser must never throw. An unreadable config has to fall back to defaults and let the app start normally. Get that wrong and a bad config push becomes a launch failure.
Here is the parser I ended up with. Unknown keys are dropped and mistyped values fall back.
export const FLAG_DEFAULTS = { schema: 1, killedFeatures: [], // feature keys to disable minSupportedBuild: 0, // show a notice below this assetBaseUrl: "https://assets.example.net/v1", notice: null, // { id, title, body, url } | null};const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);// Priority one: even an unreadable config must still let the app startexport function parseFlags(raw) { if (typeof raw !== "string" || raw.length === 0) return { ...defaults(), source: "default" }; let obj; try { obj = JSON.parse(raw); } catch { return { ...defaults(), source: "parse-error" }; } if (!isPlainObject(obj)) return { ...defaults(), source: "shape-error" }; // Bumping schema drops every older build back to defaults — a deliberate escape hatch if (obj.schema !== FLAG_DEFAULTS.schema) return { ...defaults(), source: "schema-mismatch" }; const out = { ...defaults(), source: "remote" }; if (Array.isArray(obj.killedFeatures)) { out.killedFeatures = obj.killedFeatures.filter((k) => typeof k === "string"); } if (Number.isInteger(obj.minSupportedBuild) && obj.minSupportedBuild >= 0) { out.minSupportedBuild = obj.minSupportedBuild; } if (typeof obj.assetBaseUrl === "string" && /^https:\/\//.test(obj.assetBaseUrl)) { out.assetBaseUrl = obj.assetBaseUrl; } if (isPlainObject(obj.notice) && typeof obj.notice.id === "string") { out.notice = { id: obj.notice.id, title: typeof obj.notice.title === "string" ? obj.notice.title : "", body: typeof obj.notice.body === "string" ? obj.notice.body : "", url: typeof obj.notice.url === "string" && /^https:\/\//.test(obj.notice.url) ? obj.notice.url : null, }; } return out;}
source comes back with the result so a debug menu can show where the current config came from. During a freeze, the first fork in any investigation is whether a setting is not working or simply has not arrived.
Running seven inputs through it locally produced this. With mixed types, non-string entries were dropped, the string "412" fell back to 0, and an http:// base URL was rejected.
Input
source
killedFeatures
minSupportedBuild
Empty string
default
[]
0
Malformed JSON
parse-error
[]
0
An array arrived
shape-error
[]
0
Schema mismatch
schema-mismatch
[]
0
Valid
remote
["widget-refresh"]
412
Mixed types
remote
["ok"]
0
Unknown keys
remote
[]
0
Sharing the defaults object poisoned the second read
My first version returned defaults by spreading { ...FLAG_DEFAULTS }. That is a shallow copy, so the array still pointed at the same instance.
In that state, if a caller pushes anything onto the killedFeatures it received, every subsequent "default" is contaminated. I checked, and the pushed string came back as the default on the next read.
defaults after mutation: ["accidental"]
second parse: ["accidental"]
In day-to-day operation this stays invisible. As long as config is arriving normally, the default array gets replaced and thrown away. It only surfaces on the run where the fetch failed and you fell back to defaults — in other words, on the exact path you most wanted to protect. Hit that during a freeze, when you are leaning on remote config, and a feature you never disabled stays in the disabled set.
The fix is to freeze the defaults and hand out a fresh instance every time.
Object.freeze is there so that code accidentally writing to the defaults themselves fails loudly. ES modules are evaluated in strict mode, so a push onto the frozen array raises a TypeError on the spot — I confirmed it throws rather than being silently ignored. With configuration bugs, failing fast beats failing quietly.
Audit the keys against the shipped tag, not your working tree
Reconciling "keys in the console" against "keys the code reads" is worth setting up early. But if a freeze is on the horizon, pointing the audit at the wrong tree inverts its meaning.
Reading HEAD and concluding that a key is referenced tells you nothing if that code has not shipped. What you need to audit is the tag that is currently on devices.
import { readFileSync, readdirSync, statSync } from "node:fs";import { join, extname } from "node:path";const SRC = process.argv[2] ?? "src"; // point this at a checkout of the shipped tagconst EXPORT = process.argv[3] ?? "console-export.json";const KEY_RE = /remoteConfig\.get(?:String|Boolean|Number|Value)\(\s*["'`]([\w.-]+)["'`]\s*\)/g;function walk(dir, acc = []) { for (const name of readdirSync(dir)) { const p = join(dir, name); if (statSync(p).isDirectory()) walk(p, acc); else if ([".ts", ".tsx", ".js", ".jsx"].includes(extname(p))) acc.push(p); } return acc;}const referenced = new Map();for (const file of walk(SRC)) { for (const m of readFileSync(file, "utf8").matchAll(KEY_RE)) { if (!referenced.has(m[1])) referenced.set(m[1], []); referenced.get(m[1]).push(file); }}const declared = new Set( Object.keys(JSON.parse(readFileSync(EXPORT, "utf8")).parameters ?? {}));const missing = [...referenced.keys()].filter((k) => !declared.has(k));const orphan = [...declared].filter((k) => !referenced.has(k));console.log(`referenced ${referenced.size} / declared ${declared.size}`);for (const k of missing) console.log(` x not in console: ${k} (${referenced.get(k).join(", ")})`);for (const k of orphan) console.log(` ! nobody reads: ${k}`);process.exitCode = missing.length > 0 ? 1 : 0;
On a small sample it prints something like this.
referenced 4 / declared 4
x not in console: enable_new_gallery (src/useFlags.ts)
! nobody reads: winter_campaign
Equal counts with mismatched contents show up immediately. The important part of the workflow is to expand the shipped tag into a separate tree — git worktree add ../shipped v2.1.0 — and hand the script that src. During a freeze the input to this audit is frozen too, and the fact that the referenced key list cannot change is precisely the definition of what you can still touch.
One nuance: the nobody reads side normally means dead keys to clean up. Just before a freeze it can mean the opposite — a key you placed ahead of time whose reader has not shipped yet.
Fetch and activate timing is unforgiving during a freeze
Saving a value does not push it to devices. With Firebase Remote Config, the default minimum fetch interval in production is 12 hours. Shortening it is a development affordance; use it in production and you run into throttling.
Fetch and activate are also separate operations. Fetching at launch and activating immediately often misses that session entirely, taking effect from the next cold start instead. Combine that with a cautious staged rollout starting at 5% and you get a window where a config you believe you have shipped is live on nobody's device.
That window is what hurts during a freeze. When you can still ship builds, a slow config rollout can be overtaken by a hotfix. With the release path closed, there is nothing to overtake it with. I settled on this order:
Save the current values to a local JSON file before changing anything
Roll out under a test-device condition first and confirm the debug menu shows source flipping to remote
Widen to everyone, then verify on a real device after at least one cold start
Record when it actually took effect and use that number for the next estimate
Backing out of a config you should not have shipped
If the parser never throws, malformed JSON just falls back to defaults. The harder case is a payload that is syntactically valid and semantically wrong — a feature key that should not have gone into killedFeatures, say.
You have two ways back. Paste in the values you saved beforehand, or deliberately bump schema so every device retreats to defaults at once. The second looks blunt, but when your defaults are the safe side, it is the fastest reliable escape hatch available. That is exactly why the parser above drops to defaults on a schema mismatch instead of ignoring the field.
Being able to retreat first and fix calmly afterwards makes a large difference to how a freeze feels.
What is left when the freeze lifts
Switches you planted ahead of time stay in the code after the deadline passes. The ones you never used become branch debt. Once a normal release path returns, rerun the key audit against HEAD and clear out whatever shows up under nobody reads.
This inventory knocked down several assumptions I had been carrying about remote config being a general-purpose escape hatch. What you can move is decided by the code sitting on devices, not by the settings screen. I had that direction backwards for a long stretch, so the recount was worth doing.
Start by expanding your shipped tag into a separate tree and printing the list of referenced keys. Those names are the entire control surface you would be left with on the day your updates stop.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.