●PRICE — Rork Max spans $200 to $1,800 per month, with the upper tiers aimed at heavier builders and teams●FREE — The free tier lands at roughly five prompts per week, enough to try it but not to build on continuously●SHIP — App Store publishing is automated through builds, certificates, and submission, so you can ship an iOS app without a Mac or Xcode●SIM — A browser-streamed simulator lets you watch your app run in a real Apple environment from your own browser●NATIVE — It reaches HealthKit, ARKit and LiDAR, NFC, Dynamic Island, and Metal 3D — territory React Native cannot touch●FUNDING — Rork raised a $15M seed led by Left Lane Capital, announced April 9, 2026, and acquired app builder Paperline●PRICE — Rork Max spans $200 to $1,800 per month, with the upper tiers aimed at heavier builders and teams●FREE — The free tier lands at roughly five prompts per week, enough to try it but not to build on continuously●SHIP — App Store publishing is automated through builds, certificates, and submission, so you can ship an iOS app without a Mac or Xcode●SIM — A browser-streamed simulator lets you watch your app run in a real Apple environment from your own browser●NATIVE — It reaches HealthKit, ARKit and LiDAR, NFC, Dynamic Island, and Metal 3D — territory React Native cannot touch●FUNDING — Rork raised a $15M seed led by Left Lane Capital, announced April 9, 2026, and acquired app builder Paperline
Resolving App Config in Three Layers: Merging Defaults, User, and Remote With Bounded Overrides
A single type-safe layer that merges compiled defaults, user preferences, and remote config. So a broken remote value never takes your app down, each key gets its own override strength, plus schema validation and range clamping, built from a real production incident.
Late one night I opened my own wallpaper app on my iPhone, and a full-screen ad appeared every time I swiped to the next image. Twice, three times, inside half a minute. My stomach dropped.
It wasn't a bug in the code. The interstitial cooldown value I kept in remote config had somehow been set to something tiny, and that value had shipped straight to the app. Worse, the same config was shared across all six of the wallpaper apps I maintain as an indie developer, so every one of them broke at once.
What this drove home for me was how dangerous the assumption "remote config is safe because I can fix it remotely" really is. Yes, you can push a fix quickly. But for the twenty-odd minutes it takes that fix to reach every device, the app keeps running, exposed. The place to defend was never the delivery side. It was the device receiving the value.
This article is about a design that merges three sources, compiled defaults, user preferences, and remote config, into one type-safe layer, so that a broken value can arrive without taking the app down. It's based on the code I put into all six apps after that night.
Start From the Fact That Config Has Three Sources
In most apps, config quietly ends up being three-layered whether you plan for it or not. Let me lay them out in order.
The first is the defaults compiled into the binary. They're always present, even offline. This is where the source of truth for types and initial values lives.
The second is user preferences. Stored locally on the device (AsyncStorage or MMKV), they represent choices the user made deliberately, things like prefetch count or theme.
The third is remote config. External input arriving from something like Firebase Remote Config, holding values the operator wants to change quickly for everyone, typically ad frequency or the timing of a review prompt.
The trouble is merging these three with a naive "last one wins" override. That night's incident happened precisely because remote could overwrite anything. Having three layers is unavoidable. What you must avoid is giving every layer the same strength.
Give Each Key Its Own Override Strength
The heart of the design is declaring, per value, who is allowed to override it and how far. I settled on three policies.
locked — nobody can override
A flag like which onboarding version to show breaks the experience if it flips by accident, so I mark it locked. Neither remote nor the user can touch it; it stays at its default. It's a safety valve, deliberately made immovable.
user — user preference wins
A pure preference like prefetch count gets the user policy. Remote stays out of it, and only the value the user chose overrides the default. It simply isn't a value the operator should be changing behind their back.
remote — allow overrides, but only within a range
A value the operator wants to tune, like the ad cooldown, gets the remote policy. But I don't trust it wholesale. Each key defines a min/max, and the incoming value is always clamped into that range. That 3-second value from the incident now arrives raised to a floor of 60 seconds.
✦
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
✦A type-safe merge layer that resolves defaults, user prefs, and remote config with per-key override strength (locked / user / remote)
✦How to keep one broken remote value from cascading, per-key fallback to defaults plus min/max clamping, written out in full
✦The real config incident across six wallpaper apps and the operational calls it forced (how to set bounds, what to log)
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.
Here's the implementation. First, define the defaults as a schema and make it the single source of truth for both types and initial values. I use zod for validation.
// config/resolver.tsimport { z } from "zod";// Defaults schema. The source of truth for types + initial values lives only here.const ConfigSchema = z.object({ interstitialCooldownSec: z.number().int().min(30).max(600), reviewPromptAfterOpens: z.number().int().min(3).max(50), imagePrefetchCount: z.number().int().min(0).max(40), showOnboardingV2: z.boolean(),});export type AppConfig = z.infer<typeof ConfigSchema>;const DEFAULTS: AppConfig = { interstitialCooldownSec: 90, reviewPromptAfterOpens: 8, imagePrefetchCount: 12, showOnboardingV2: false,};// Per-key override policy// locked … neither remote nor user may touch it// user … only the user preference may override the default// remote … remote may override, but is always clamped to min/maxtype Policy = | { kind: "locked" } | { kind: "user" } | { kind: "remote"; min: number; max: number };const POLICIES: Record<keyof AppConfig, Policy> = { interstitialCooldownSec: { kind: "remote", min: 60, max: 300 }, reviewPromptAfterOpens: { kind: "remote", min: 5, max: 20 }, imagePrefetchCount: { kind: "user" }, showOnboardingV2: { kind: "locked" },};
I keep the policy's min/max separate from the schema's min/max on purpose. The schema is "the range of values we'll accept at all," while the policy is "the operational range we'll allow remote to use." The former is wide, the latter narrow. That two-stage framing pays off later.
Next, the resolver itself. Incoming remote values are treated as untrusted external input, validated and clamped one key at a time.
type Sources = { user: Partial<AppConfig>; remote: Record<string, unknown>; // untrusted external input};const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));export function resolveConfig(sources: Sources): { config: AppConfig; rejected: string[];} { const out: AppConfig = { ...DEFAULTS }; const rejected: string[] = []; for (const key of Object.keys(DEFAULTS) as (keyof AppConfig)[]) { const policy = POLICIES[key]; if (policy.kind === "locked") continue; // stays at the default if (policy.kind === "user") { const val = sources.user[key]; if (val !== undefined) (out[key] as unknown) = val; continue; } // policy.kind === "remote" const raw = sources.remote[key]; if (raw === undefined) continue; // If the type is off, drop just this one key back to the default (don't break the whole object) const field = ConfigSchema.shape[key]; const parsed = field.safeParse(raw); if (!parsed.success) { rejected.push(`${key}: ${parsed.error.issues[0]?.message ?? "invalid"}`); continue; } // Numbers are always pulled inside the policy's bounds const value = parsed.data; (out[key] as unknown) = typeof value === "number" ? clamp(value, policy.min, policy.max) : value; } return { config: out, rejected };}
The most important thing here is that a key which fails validation is skipped with continue, leaving that single key at its default. We never throw away the whole object because one key was rotten. Don't toss the whole crate over one bad apple.
Prove "Don't Trust Remote Blindly" With Behavior
Words alone don't land, so let's write a test that pours broken values in. It doubles as the spec.
// resolver.test.tsimport { resolveConfig } from "./resolver";test("a broken remote value falls back to the app default", () => { const { config, rejected } = resolveConfig({ user: { imagePrefetchCount: 24 }, remote: { interstitialCooldownSec: 3, // too small -> clamped to floor 60 reviewPromptAfterOpens: "soon", // type violation -> falls back to default 8 showOnboardingV2: true, // locked -> ignored }, }); expect(config.interstitialCooldownSec).toBe(60); // clamped expect(config.reviewPromptAfterOpens).toBe(8); // fallback expect(config.imagePrefetchCount).toBe(24); // user override expect(config.showOnboardingV2).toBe(false); // still locked expect(rejected.length).toBe(1);});
That 3-second value from the incident is literally the first line of this test. Now it arrives clamped to 60 seconds. The stray string in the review prompt quietly falls back to the default of 8. The locked flag doesn't move no matter what shows up. There's a quiet satisfaction in the incident itself becoming the regression test.
Keep It Behind a Single Hook in React
I don't want this complexity leaking into the call sites. The resolution is sealed inside one hook, and screens receive only a finished AppConfig.
// config/useConfig.tsimport { useMemo } from "react";import { resolveConfig, type AppConfig } from "./resolver";import { useUserPrefs } from "./userPrefs"; // local to the deviceimport { useRemoteConfig } from "./remoteConfig"; // raw values from Firebase, etc.export function useConfig(): AppConfig { const user = useUserPrefs(); const remote = useRemoteConfig(); return useMemo(() => { const { config, rejected } = resolveConfig({ user, remote }); if (rejected.length > 0) { // In production, never swallow this. Always log it. This is what pays off. console.warn("[config] rejected keys", rejected); } return config; }, [user, remote]);}
A screen just writes const { interstitialCooldownSec } = useConfig();. It never has to know there are three layers, or that clamping ran. Seal the complexity in one place and expose only typed, trustworthy values outward. That's the whole point of a resolution layer.
Filling the Few Seconds Before Remote Values Arrive
Even when the three-layer merge works correctly, cold start leaves a hole. Remote config travels over the network, so at the moment the app paints its first screen, nothing has arrived yet.
Implemented naively, those few seconds run entirely on defaults. That's fine in most cases. But when you're mid-flight with a value the operator deliberately loosened — an ad cooldown, say — you get an odd wobble: the strict default right after launch, then a loosening a moment later. In my wallpaper apps this surfaced in review prompt timing, where some users saw the rating dialog appear early only in the first seconds after launch.
The fix I added was keeping the last accepted raw remote payload on the device.
// config/remoteCache.tsimport AsyncStorage from "@react-native-async-storage/async-storage";const KEY = "config.remote.cache.v1";const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // anything older than 7 days is unusabletype Cached = { at: number; values: Record<string, unknown> };export async function saveRemoteCache(values: Record<string, unknown>) { const payload: Cached = { at: Date.now(), values }; await AsyncStorage.setItem(KEY, JSON.stringify(payload));}export async function loadRemoteCache(): Promise<Record<string, unknown>> { const raw = await AsyncStorage.getItem(KEY); if (!raw) return {}; try { const c: Cached = JSON.parse(raw); if (Date.now() - c.at > MAX_AGE_MS) return {}; // quietly discard stale values return c.values; } catch { return {}; // corrupted? fall back to defaults }}
What matters here is that we store the raw values from before resolveConfig, not the resolved result. Save the resolved config and you freeze it against whatever bounds were in effect at the time — tighten a policy's min/max later and the cache keeps handing back a value the new policy would never allow. Kept raw, it gets re-resolved against the current policy on every launch.
I settled on a seven-day expiry because a delivery older than that tells you nothing useful. A week is long enough for operational intent to have moved on. Expired caches are dropped silently and the app returns to defaults.
On the boot path, the fetch itself also carries a timeout.
// config/remoteConfig.ts (excerpt)const FETCH_TIMEOUT_MS = 2500;const withTimeout = <T,>(p: Promise<T>, ms: number): Promise<T | null> => Promise.race([p, new Promise<null>((r) => setTimeout(() => r(null), ms))]);export async function bootstrapRemote(): Promise<Record<string, unknown>> { const fresh = await withTimeout(fetchRemoteValues(), FETCH_TIMEOUT_MS); if (fresh) { await saveRemoteCache(fresh); // keep the raw payload for next launch return fresh; } return await loadRemoteCache(); // otherwise last known values, or nothing}
Fresh values if they land, last known values if they don't, defaults if neither exists. With three stages in place something always comes back regardless of connectivity, and the screen never waits. Even an empty object is fine, because resolveConfig fills every key from defaults — the calling side gains no extra branches at all.
This machinery is the foundation for delivering values safely, which is a different job from turning features on and off. If you want per-feature kill switches or staged enablement, pairing this with the design in Making Feature Flags Survive Production: Kill Switches and Gradual Rollouts in Rork Max proved the practical split: the config layer guards value bounds, the flag layer guards whether a feature runs at all.
Making the Source Layer Visible on the Device
The first thing that tripped me up in operation was that a value alone tells you nothing about its path. Say the device holds 90 seconds. Is that the default 90, a remote delivery of 90, or an extreme value clamped down to 60? The number can't distinguish them.
So I had the resolver return the origin of every key alongside the value.
// config/resolver.ts (addition)export type Origin = "default" | "user" | "remote" | "clamped" | "rejected";export function resolveConfigWithOrigin(sources: Sources): { config: AppConfig; origin: Record<keyof AppConfig, Origin>; rejected: string[];} { const out: AppConfig = { ...DEFAULTS }; const origin = {} as Record<keyof AppConfig, Origin>; const rejected: string[] = []; for (const key of Object.keys(DEFAULTS) as (keyof AppConfig)[]) { const policy = POLICIES[key]; origin[key] = "default"; if (policy.kind === "locked") continue; if (policy.kind === "user") { const val = sources.user[key]; if (val !== undefined) { (out[key] as unknown) = val; origin[key] = "user"; } continue; } const raw = sources.remote[key]; if (raw === undefined) continue; const parsed = ConfigSchema.shape[key].safeParse(raw); if (!parsed.success) { rejected.push(`${key}: ${parsed.error.issues[0]?.message ?? "invalid"}`); origin[key] = "rejected"; // stays on default, but records "arrived and refused" continue; } const value = parsed.data; if (typeof value === "number") { const clamped = clamp(value, policy.min, policy.max); (out[key] as unknown) = clamped; origin[key] = clamped === value ? "remote" : "clamped"; } else { (out[key] as unknown) = value; origin[key] = "remote"; } } return { config: out, origin, rejected };}
The crux is treating rejected and default as separate origins. Both end on the default value, but they mean entirely different things: one is "it arrived and I didn't trust it," the other is "nothing arrived at all." Back when I collapsed them into one, I couldn't separate a delivery mistake from a dead network, and I started every investigation in the wrong place.
Splitting out clamped follows the same reasoning. A value that keeps pinning to the boundary is a signal that the level the operator wants and the ceiling the policy allows have drifted apart. It gives you grounds to revisit the bound itself rather than the delivery.
The debug screen is a list that only ships in development builds.
Origin shown
What it means on screen
Where to look next
default
Key absent from remote, or nothing fetched yet
Key names on the delivery side; whether the fetch succeeded
user
Value the user chose on the device
The save path in your settings screen
remote
Delivered value passed through untouched
Nothing to do. Working as intended
clamped
Rounded to a boundary
Gap between policy min/max and the delivered value
rejected
Refused on a type mismatch
Types on the delivery side. Usually string vs. number
Since adding this, config investigations take me less than half the time they used to. Where I once bounced between the delivery console and device logs, I now open one physical device and have an answer.
I keep the screen out of release builds entirely. The origin data itself is harmless, but there's no reason to ship a list of boundary values and key names inside the binary. Beyond the __DEV__ branch, the screen's import is confined to development builds.
Deciding what level a number should sit at is beyond this article's scope. For how to arrive at ad frequency itself, From Rork to $1,000/month with AdMob — A Practical Indie Developer's Monetization Playbook walks through the actual figures, which may serve as the basis for setting your bounds. The config layer defends safety; finding the right value is separate work.
What Went Against My Intuition: Making Remote Powerful Caused the Incident
Let me be honest. Before the incident, I believed keeping remote config as powerful as possible was the good design, that being able to change anything instantly from the operator side would make operations easier. The reality was the opposite. Being able to change anything meant being able to break anything.
The other surprise was that the rejected log turned out to be a goldmine. At first, logging rejected values felt like a just-in-case precaution. In practice, delivery mistakes, careless type errors, and leftover old key names all show up in that log first. In my own records, roughly 3% of remote deliveries carried some type mismatch or out-of-range value. I could catch it before a single user noticed anything wrong. The very act of rejecting at the boundary turned out to be the best monitoring I had.
Official docs are thorough about how to set up remote config, but "how far to trust the value once it arrives" is left as the app's responsibility. Here are a few of the operational calls I made across the six apps.
Decision
What I chose
Why
Setting bounds
Put min/max just inside the range that has been safe in practice
The more aggressive the value you want to try, the more you want the failure swing narrowed first
Handling rejects
Never drop them; always log, review counts weekly
It becomes the fastest detection path for config mistakes
Where to use locked
Limit it to flags tied to the skeleton of the experience
Add too many safety valves and you lose the ability to fix things quickly
Schema vs. policy split
Acceptance range wide, operational range narrow, in two stages
You want to tune production tolerance separately from basic validity
There comes a stage where you want to compare which value inside the bounds actually performs best. Past that point, clamping in the resolver isn't enough — you need to split delivery and measure outcomes. I wrote up how to assemble that in Building a Rork A/B Testing Platform with GrowthBook and PostHog from Scratch. The three-layer resolution here is the groundwork underneath it, guaranteeing nothing breaks no matter which value arrives.
If I could add only one thing today, I'd recommend starting with the clamp in the remote policy. Even without turning every key into a policy, just wrapping incoming numbers in a single min/max stops cascades like that night almost entirely. You can grow the locked and user classifications gradually as you operate.
Config is a humble layer. But it's also the layer that's most visible in the user's hands when it breaks. If you take away nothing but "defend on the side that receives the value," this article will have been worth writing. Thank you for reading.
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.