+1 (415) 843-4662

Feature flags, staged rollouts and kill switches for Ionic apps

Every Ionic team we work with eventually hits the same wall: the release train is weekly, the app store review is unpredictable, and one bad feature reaches 100% of users at once with no way back except a new binary. The fix is boring and it is the same fix native teams use — put every risky change behind a flag, ramp it slowly, and keep a kill switch that works without a store release.

This tutorial builds that system for an Ionic + Capacitor app: a flag service with offline-safe caching, deterministic bucketing so a user never flickers between variants, staged rollouts tied to app version, a kill switch that reaches users in seconds, and the wiring to Play Console / App Store Connect staged releases and your self-hosted live updates. Examples are Ionic Angular and Ionic React; the core service is framework-agnostic TypeScript.

Why this matters more on Ionic than on native

Two reasons specific to our stack:

  1. You ship web assets. Because a large part of an Ionic app is a web bundle, a bad change can be pushed and pulled back quickly — but only if you built the gate. Without flags, an over-the-air update is a loaded gun.
  2. Appflow's wind-down moved rollout control in-house. Teams that moved Live Updates to a self-hosted channel (see our Live Updates migration guide) now own percentage rollouts themselves. Flags are the half of that story that most migrations forget.

1. The data model

Keep the payload dumb. A flag is a name, a default, and a list of rules evaluated top to bottom:

{
  "version": 184,            // bumped on every publish; used for caching
  "flags": {
    "new_checkout": {
      "default": false,
      "rules": [
        { "if": { "userTier": "internal" }, "value": true },
        { "if": { "minAppVersion": "7.2.0" }, "rollout": 10, "value": true }
      ]
    },
    "ai_copilot_enabled": { "default": true, "rules": [] }
  }
}

Three deliberate constraints:

  • Evaluation happens on the device. One small JSON document, cached, evaluated locally. No per-flag network call, no latency on first paint.
  • Every flag has a safe default — the value that means "old behaviour". If the network is down, if the JSON is corrupt, if the flag was deleted, the user gets the default and nothing breaks.
  • minAppVersion is mandatory for anything that needs native support. A flag that turns on a feature requiring a Capacitor plugin added in 7.2.0 must never evaluate true on 7.1.x. This is the single most common production incident we are called in to clean up.

Serve the document from a CDN edge with a short TTL (30–60s), or use a hosted provider. The client contract is identical either way.

2. The evaluation service

// src/flags/flags.ts
import { Preferences } from '@capacitor/preferences';
import { App } from '@capacitor/app';

export type FlagContext = {
  userId: string;          // stable, non-rotating; anonymous id before login
  userTier?: string;
  appVersion: string;
  platform: 'ios' | 'android' | 'web';
};

type Rule = {
  if?: Record<string, string>;
  rollout?: number;        // 0-100
  value: unknown;
};
type Doc = { version: number; flags: Record<string, { default: unknown; rules: Rule[] }> };

const CACHE_KEY = 'flags.doc.v1';
const URL = 'https://flags.example.com/v1/app.json';

let doc: Doc = { version: 0, flags: {} };
let ctx: FlagContext;

export async function initFlags(context: FlagContext) {
  ctx = context;
  // 1. cached value first: evaluation must be synchronous from here on
  const { value } = await Preferences.get({ key: CACHE_KEY });
  if (value) { try { doc = JSON.parse(value); } catch { /* keep defaults */ } }
  // 2. refresh in the background; never block startup on this
  void refreshFlags();
  // 3. and again whenever the app returns to the foreground
  App.addListener('resume', () => void refreshFlags());
}

export async function refreshFlags() {
  try {
    const res = await fetch(`${URL}?v=${ctx.appVersion}`, { cache: 'no-store' });
    if (!res.ok) return;
    const next = (await res.json()) as Doc;
    if (typeof next?.version !== 'number') return;
    doc = next;
    await Preferences.set({ key: CACHE_KEY, value: JSON.stringify(next) });
  } catch {
    // offline: keep the cached document. Never throw out of here.
  }
}

export function flag<T>(name: string, fallback: T): T {
  const def = doc.flags[name];
  if (!def) return fallback;
  for (const rule of def.rules ?? []) {
    if (!matches(rule, ctx)) continue;
    if (rule.rollout != null && bucket(ctx.userId, name) >= rule.rollout) continue;
    return rule.value as T;
  }
  return (def.default as T) ?? fallback;
}

flag() is synchronous on purpose. An async flag check inside a render path produces a visible flicker between the default and the real value, and that flicker is what users report as "the app glitched".

3. Deterministic bucketing

A rollout percentage is useless if a user lands in a different bucket on each launch. Hash the user id together with the flag name so the assignment is stable per user and independent across flags:

// FNV-1a: tiny, fast, no dependency, good enough for bucketing
function bucket(userId: string, flagName: string): number {
  const input = `${flagName}:${userId}`;
  let h = 0x811c9dc5;
  for (let i = 0; i < input.length; i++) {
    h ^= input.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return (h >>> 0) % 100;          // 0..99
}

function matches(rule: Rule, c: FlagContext): boolean {
  const cond = rule.if ?? {};
  if (cond.platform && cond.platform !== c.platform) return false;
  if (cond.userTier && cond.userTier !== c.userTier) return false;
  if (cond.minAppVersion && cmpSemver(c.appVersion, cond.minAppVersion) < 0) return false;
  return true;
}

function cmpSemver(a: string, b: string): number {
  const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
  for (let i = 0; i < 3; i++) {
    if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
  }
  return 0;
}

Hashing flagName:userId rather than the user id alone matters: if you hash only the id, the same unlucky 10% of users are in the first wave of every rollout and they carry all the breakage.

4. Wiring it into the app

Initialise before the first route renders, using the real binary version from @capacitor/app so minAppVersion means what you think it means:

// main.ts
import { App } from '@capacitor/app';
import { Capacitor } from '@capacitor/core';
import { initFlags } from './flags/flags';

const info = Capacitor.isNativePlatform()
  ? await App.getInfo()
  : { version: import.meta.env.VITE_APP_VERSION as string };

await initFlags({
  userId: await getStableUserId(),
  appVersion: info.version,
  platform: Capacitor.getPlatform() as 'ios' | 'android' | 'web',
});

Ionic Angular — a one-line structural directive:

@Directive({ selector: '[appFlag]', standalone: true })
export class FlagDirective {
  private shown = false;
  constructor(private tpl: TemplateRef<unknown>, private vc: ViewContainerRef) {}
  @Input() set appFlag(name: string) {
    const on = flag<boolean>(name, false);
    if (on && !this.shown) { this.vc.createEmbeddedView(this.tpl); this.shown = true; }
    if (!on && this.shown) { this.vc.clear(); this.shown = false; }
  }
}
<ion-button *appFlag="'new_checkout'" (click)="openNewCheckout()">Checkout</ion-button>

Ionic React — a hook plus a guard component:

export function useFlag<T>(name: string, fallback: T): T {
  return useSyncExternalStore(subscribeToFlags, () => flag(name, fallback));
}

export const Flag: React.FC<{ name: string; children: React.ReactNode }> =
  ({ name, children }) => (useFlag(name, false) ? <>{children}</> : null);

For routes, gate at the router level, not inside the page — a flagged-off route should 404 or redirect, not render an empty shell.

5. Staged rollout: the actual sequence

A rollout is a schedule, not a switch. The one we run with clients:

StageAudienceMinimum soakGate to advance
0Internal (userTier: internal)2 daysNo new crash signatures
11%24hCrash-free sessions within 0.1% of baseline
210%48hPrimary funnel metric flat or better
350%48hSupport ticket volume flat
4100%—Remove the flag within two sprints

Two things make this real rather than theatre:

  • Report the variant with every event. Your analytics, crash reporter and performance traces all need the flag state attached, or you cannot tell whether the 10% cohort is worse. Set it as a user property at init and update on refresh — see our crash reporting guide for the tagging pattern.
  • Compare cohorts, not weeks. "Crashes went up Tuesday" tells you nothing. "Crash-free sessions are 99.1% in the flag-on cohort and 99.7% in the flag-off cohort on the same build" tells you to roll back.

Keep this aligned with the store's own staged release. Play Console staged rollout and App Store phased release ramp the binary; your flags ramp the behaviour. Ship the binary dark — feature compiled in, flag off — let the store rollout finish, then ramp the flag. Separating the two means that when something breaks you know which one to reverse.

6. The kill switch

A kill switch is a flag with a different contract: it must take effect fast, and it must work even if your flag document is stale.

// Ultra-short TTL document, checked on every foreground and every 5 minutes
export function isKilled(feature: string): boolean {
  return flag<boolean>(`kill_${feature}`, false);
}

Three rules we enforce:

  1. Check at the point of use, not only at startup. A session that started before you flipped the switch must still honour it on the next tap.
  2. Degrade, don't crash. isKilled('ai_copilot') hides the entry point and shows the previous path. Never throw.
  3. Pair it with a server-side block. Old binaries and users who never foreground the app will keep calling the endpoint. The flag protects the UX; the API returning 410 protects the system.

For a true emergency on the web-asset layer, the live-update channel is your second lever: push the previous bundle and the flag becomes irrelevant. Keep both, because they fail in different ways — the update channel needs a successful download, the flag needs a successful fetch of a tiny JSON file.

7. Testing flagged code

Flags double your states, so be disciplined:

  • Unit-test flag() against a fixture document: default, each rule, version gating, bucket boundaries at 0 and 99.
  • In E2E, inject the document rather than hitting the network — a window.__FLAGS__ override read by initFlags in dev/test builds keeps Playwright and Maestro runs deterministic (see our E2E testing guide).
  • Run the full E2E suite twice in CI, once with all new flags on and once with all off. Anything else and you are shipping an untested combination.
  • Add a hidden debug screen (long-press the version string) that lists flags, their evaluated value and the rule that matched. QA will stop filing "cannot reproduce" tickets.

8. Cleaning up

Flags are debt. Every one of them is a branch that must keep working. Our house rules:

  • Each flag gets an owner and an expiry date at creation, recorded in the flag document itself.
  • A CI job fails the build when a flag is past expiry or when a flag name no longer appears anywhere in the source.
  • Removing a flag means deleting the losing branch and the config entry, in that order, in a PR that touches nothing else.
  • Permanent operational switches (kill switches, provider toggles) live in a separate ops_ namespace and are exempt — be explicit about which flags are permanent so the rest can be deleted without debate.

9. Build or buy

A JSON document on a CDN plus the ~80 lines above covers maybe 80% of teams, costs nothing, and has no SDK in your bundle or vendor in your privacy manifest. Buy a managed platform when you need per-flag audit trails for compliance, non-engineers changing flags through a UI, or real experiment analysis with statistical significance built in. Either way, keep the flag() call site in your own thin wrapper so swapping the backend is a one-file change — and check any vendor SDK against your iOS privacy manifest and Play Data Safety declarations before adopting it.


Feature flags are the cheapest insurance an Ionic app can buy: a day of engineering that turns "we have to ship a hotfix through review" into "we moved a number from 10 to 0". We build this into client apps as part of release-engineering work, alongside self-hosted live updates and CI/CD. Get in touch if you want a rollout process that lets your team ship on Friday.