BUILT INTO EVERY GOODSPEED APP
Feature Flags + Kill Switch
Server-controlled feature flags stored in a Supabase table support per-user rollout percentages and segment targeting via a useFlag hook that re-polls once a minute; any flag prefixed kill_ acts as an instant kill switch that propagates under a minute.
- Tier: Core
- Status: Static
- Always enabled
WHY IT MATTERS
Shipping new behavior to all users at once is a bet you rarely want to take. A gradual rollout that starts at 5 percent of users, checks retention, then climbs to 50 and finally 100 is the difference between a smooth launch and an emergency rollback. Wiring that logic yourself means a flags table, a consistent hashing function, a caching layer, and a React hook that wires them together and re-renders on change. The GAS template ships the complete stack. Every generated app includes a feature_flags Supabase table with key, enabled, rollout_percentage, and a segments JSONB column for country and user-segment targeting. The useFlag(key) hook reads from an in-memory snapshot, triggers a background refresh if the snapshot is older than a minute, and subscribes to a pub/sub listener set so any component that depends on a flag re-renders the instant a background poll delivers a change. Cold starts are covered too: the snapshot is persisted to the offline cache so users on a slow connection see the last-known state instead of the default falsy value while the first fetch is in-flight.
The kill switch convention costs nothing to implement and pays for itself the first time you need it. Any flag whose key starts with kill_ is a special case inside resolveFlag: when kill_all is present and enabled, every flag call in the entire app returns false regardless of the individual row. A targeted kill switch like kill_new_editor disables only the feature it names, leaving the rest of the app running normally. Because the hook re-polls once a minute, the maximum propagation delay from the moment an operator flips kill_all to the moment all connected clients see it is the length of one polling cycle. No app store update, no EAS push, no coordinated deploy required. The admin screen at app/(admin)/flags.tsx lists every row with inline enable/disable toggles and a rollout-percentage input so operators can adjust live rollout without touching a database client.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from lib/feature-flags.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// lib/feature-flags.ts (gas-template) -- hook + evaluation core
const TTL_MS = 60_000; // re-poll once a minute
// Deterministic per-(userId, key) bucket: stable across re-renders and refreshes.
function bucket(userId: string, key: string): number {
const cacheKey = `${userId}:${key}`;
const cached = bucketCache.get(cacheKey);
if (cached !== undefined) return cached;
const value = assignBucket(userId, key, 100);
bucketCache.set(cacheKey, value);
return value;
}
// Evaluation order: kill_all -> enabled -> country -> user_segment -> rollout%
function resolveFlag(key: string, defaultValue: boolean): boolean {
const killAll = snapshot['kill_all'];
if (killAll?.enabled === true) return false; // kill switch: everything off
const row = snapshot[key];
if (!row) return defaultValue;
const uid = userCtx?.userId ?? (deviceId ?? 'anonymous');
return evaluate(row, uid, userCtx?.country, userCtx?.segment);
}
/** React hook -- subscribes to snapshot, re-renders on background polls. */
export function useFlag(key: string, defaultValue = false): boolean {
const [value, setValue] = useState<boolean>(() => resolveFlag(key, defaultValue));
useEffect(() => {
let cancelled = false;
ensureFresh().then(() => {
if (!cancelled) setValue(prev => {
const next = resolveFlag(key, defaultValue);
return prev === next ? prev : next;
});
}).catch(() => { /* silent */ });
const onUpdate = () => {
if (!cancelled) setValue(prev => {
const next = resolveFlag(key, defaultValue);
return prev === next ? prev : next;
});
};
listeners.add(onUpdate);
return () => { cancelled = true; listeners.delete(onUpdate); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return value;
}Source: goodspeed-apps/gas-template → lib/feature-flags.ts
HONEST LIMITS
When Feature Flags + Kill Switch is the wrong choice
Feature flags backed by a database poll are the right tool when runtime behavior needs to change without a new build. They are the wrong tool when the gate is purely about build environment. If a feature should only run in development (verbose logging, debug overlays, test harnesses), use an environment variable checked at build time. Polling a Supabase table at runtime to decide whether to show a debug panel adds a network dependency for a value that will never change in production and can never be toggled by an operator anyway. Use process.env.EXPO_PUBLIC_* or a gas.config.ts compile-time constant for those cases. The 60-second polling interval also means feature flags are not a real-time safety mechanism for cases where you need sub-second response. If your app handles financial transactions or safety-critical workflows where a flag change needs to take effect within milliseconds of an operator action, the polling model is too slow. In those scenarios, pair a Supabase Realtime subscription with the flag table so updates are pushed to connected clients immediately, and treat the 60-second poll as the fallback for clients that reconnect after a WebSocket drop. The polling-only model shipped in the template is correct for the vast majority of use cases, where a 60-second propagation window is an acceptable operational tradeoff.
Tier: Core · Static
Evaluate your use case
Check whether feature flags + kill switch aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
This feature is always enabled. If you need to disable it, remove the corresponding template files after generation.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Every generated Goodspeed app includes feature flags + kill switch. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
Feature Flags + Kill Switch capability breakdown
Concrete dimensions of what the built-in feature flags + kill switch implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Storage backend | Flags live in the feature_flags Supabase Postgres table. Reads use the public anon key (public read RLS policy); writes require admin credentials via app/(admin)/flags.tsx. | Supabase Postgres |
| Poll interval | useFlag re-polls once a minute using an in-memory TTL. Concurrent callers share one in-flight fetch via a singleton promise, so N hook instances do not issue N requests. | 60-second TTL |
| Rollout bucketing | assignBucket(userId, key, 100) hashes the user ID and flag key into a deterministic 0-99 integer. The same user always lands in the same bucket for the same flag, so rollout is stable across sessions and devices. | Deterministic hash |
| Segment targeting | The segments JSONB column supports a countries string array and a user_segment string. setUserContext(userId, country, segment) registers the active user context for evaluation. | Country + segment |
| Kill switch | Any flag key prefixed kill_ short-circuits evaluation. kill_all returns false for every flag in the app. Targeted kill_ flags disable only the named feature. Propagation delay is at most one 60-second poll cycle. | kill_ prefix convention |
COMMON QUESTIONS
How does the 60-second poll work without hammering Supabase on every re-render?
The module keeps a module-level lastFetchedAt timestamp and an in-flight fetchPromise singleton. When useFlag mounts or any caller invokes ensureFresh(), the function checks whether Date.now() - lastFetchedAt exceeds a minute. If not, it returns immediately from the in-memory snapshot without issuing any network call. If the snapshot is stale and a fetch is already in flight, the caller awaits the existing fetchPromise rather than starting a new one. This means any number of components mounting simultaneously share one Supabase query per one-minute window. The snapshot is also persisted to the offline cache on every successful fetch, so a cold start that cannot reach Supabase immediately returns the last-known values rather than falling through to all-false defaults.
How do I use a feature flag in a screen component?
Call useFlag('your_flag_key') at the top level of your component. It returns a boolean. The hook is synchronous on first render (it reads from the in-memory snapshot) and triggers a background refresh if the snapshot is stale. If a background refresh changes the flag value, the component re-renders automatically via the pub/sub listener. For imperative code outside React components, use the synchronous getFlag('your_flag_key', false) function, which reads from the same snapshot and kicks off a background refresh if needed. Set the user context once from your auth state listener via setUserContext(userId, country, segment) so rollout bucketing and segment targeting use the real user identity rather than the anonymous device ID fallback.
What happens if the Supabase fetch fails?
The fetchFlags function wraps the Supabase call in retryWithBackoff with two retries and a 500ms base delay, retrying only on transient non-4xx errors (network timeouts, 503s). If all retries fail, the error is swallowed and addBreadcrumb fires a fetch_failed event to Sentry so the silent fallback is observable in your error dashboard. The snapshot is not cleared on failure, so the app continues serving the last successfully fetched values rather than falling back to all-false defaults. A hydrateFromCache call at startup populates the snapshot from the offline cache before the first network fetch completes, so even a user who launches the app with no connectivity sees real flag values rather than defaults on first render.
Can I use feature flags for anonymous users before they sign in?
Yes. The module maintains a stable device ID stored in expo-secure-store on native and localStorage on web. When no user context has been set via setUserContext, resolveFlag falls back to the device ID as the bucket key. This means rollout percentages are consistent for anonymous users across app restarts: the same device always lands in the same bucket for the same flag. Once the user signs in and setUserContext is called with their real user ID, the bucket cache is cleared and subsequent evaluations use the authenticated identity. The transition is seamless from the component perspective: useFlag re-evaluates and re-renders if the authenticated bucket produces a different result than the anonymous one.
GET IT BUILT INTO YOUR APP