Remote Feature Flags Without Remote Code

Ship kill switches and gradual rollouts in an MV3 extension with a remote flags document: typed defaults, validation, caching, stable percentage bucketing, refresh on alarms and staying within store policy.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

A new feature ships, and two hours later the error dashboard lights up: it breaks a popular site. The fix is a store release, which takes anywhere from hours to days to clear review and reach users. A remote kill switch would have turned the feature off in minutes. Flag services built for web apps load an SDK from a CDN and sometimes evaluate rules as code — neither is allowed in MV3. A flags system for an extension is simpler: a small JSON document of typed values, fetched by the service worker, validated, cached, and used only to choose between behaviours already in the package. This guide builds one. It belongs to usage analytics and feature flags.

Why flags are allowed when remote code is not

Store policies forbid executing logic that was not in the reviewed package. A flag that says "newSummariser": false does not add logic; it selects between two code paths that reviewers have already seen. That distinction holds as long as the flags document stays a set of typed values — booleans, numbers, enumerated strings, small lists. It stops holding the moment the document can express behaviour: selectors with actions, conditions in a mini-language, URLs of scripts to load. Keep the document dumb and the code smart, and flags are both safe and compliant. The same principle protects users: if your flags host is ever compromised, the worst an attacker can do is switch features you shipped on or off.

Layers of a flag valuePackaged defaults at the bottom, the last fetched remote document cached in storage, local overrides for development, and the evaluated flag returned to code.Evaluated flagwhat code readsflag("newSummariser")Local overridedev builds onlyoptions page toggleCached remotelast valid flags.jsonstorage.localPackaged defaultssafe valuesalways present
Defaults guarantee a value; remote values adjust it; overrides win in development.

Step-by-step: a small, safe flags system

1. Declare flags with typed, safe defaults

1// flags/defaults.js
2export const FLAGS = Object.freeze({
3  newSummariser:     { type: "boolean", default: false },
4  summariserPercent: { type: "number",  default: 0, min: 0, max: 100 },
5  highlightStyle:    { type: "enum",    default: "underline", values: ["underline", "background"] },
6  disabledOnHosts:   { type: "list",    default: [], max: 200 },     // kill switch per site
7});

Execution context: a module shared by all contexts. Every flag has a default that is safe if the network is unreachable forever — usually “off” for new features. Types and bounds are part of the declaration, so validation in step 3 is generic. disabledOnHosts is a common, valuable flag: it lets you stop a content script from acting on a site where it causes breakage, without touching any other site.

2. Serve a plain JSON document

1{
2  "version": 42,
3  "flags": {
4    "newSummariser": true,
5    "summariserPercent": 10,
6    "highlightStyle": "background",
7    "disabledOnHosts": ["broken.example.com"]
8  }
9}

Execution context: a static file on HTTPS infrastructure you control — object storage behind a CDN works well. A monotonically increasing version lets clients ignore an older cached copy served by a stale edge. Changes to this file change behaviour for every user, so put it under version control with review, exactly like code.

3. Fetch, validate and cache in the worker

 1// flags/remote.js
 2export async function refreshFlags() {
 3  let doc;
 4  try {
 5    const res = await fetch("https://flags.readable.example/v1/flags.json", { cache: "no-cache", signal: AbortSignal.timeout(5000) });
 6    if (!res.ok) return;
 7    doc = await res.json();
 8  } catch { return; }                                        // offline: keep last good
 9
10  const { flagsDoc } = await chrome.storage.local.get("flagsDoc");
11  if (flagsDoc && doc.version <= flagsDoc.version) return;   // never go backwards
12
13  const clean = {};
14  for (const [name, spec] of Object.entries(FLAGS)) {
15    const v = doc.flags?.[name];
16    if (valid(spec, v)) clean[name] = v;
17  }
18  await chrome.storage.local.set({ flagsDoc: { version: doc.version, flags: clean, at: Date.now() } });
19}
20
21function valid(spec, v) {
22  switch (spec.type) {
23    case "boolean": return typeof v === "boolean";
24    case "number":  return typeof v === "number" && v >= spec.min && v <= spec.max;
25    case "enum":    return spec.values.includes(v);
26    case "list":    return Array.isArray(v) && v.length <= spec.max && v.every((x) => typeof x === "string" && x.length < 256);
27  }
28  return false;
29}

Execution context: the service worker. Invalid or unknown values are dropped individually, so one typo does not discard the whole document. Refusing to go backwards protects against a CDN serving a stale copy. The cached document persists across restarts, so a user offline for a week still runs with the last flags they received.

A kill switch reaching usersAn engineer adds a host to disabledOnHosts and publishes version 43; the next alarm in each worker fetches it, validates it, caches it and broadcasts a change; content scripts on that host stop acting.Engineerflags.jsonService workerContent scriptpublish v43 (+ broken.example.com)alarm: fetchv43validate + cachestorage.onChangedteardown o…
Minutes from decision to effect — limited by the refresh interval, not by store review.

4. Refresh on a schedule and on startup

1chrome.runtime.onStartup.addListener(refreshFlags);
2chrome.runtime.onInstalled.addListener(refreshFlags);
3chrome.alarms.create("flags-refresh", { periodInMinutes: 30 });
4chrome.alarms.onAlarm.addListener(({ name }) => name === "flags-refresh" && refreshFlags());

Execution context: the service worker, all listeners at the top level. Thirty minutes is a reasonable balance between kill-switch latency and request volume; with a CDN, the cost of frequent refreshes is negligible. If you have push set up, a push message can trigger an immediate refresh for urgent changes — see receiving server push in an extension.

5. Evaluate flags, including stable percentage rollouts

 1// flags/eval.js
 2export async function flag(name) {
 3  const spec = FLAGS[name];
 4  const { flagsDoc, flagOverrides = {} } = await chrome.storage.local.get(["flagsDoc", "flagOverrides"]);
 5  if (IS_DEV && name in flagOverrides) return flagOverrides[name];
 6  return flagsDoc?.flags?.[name] ?? spec.default;
 7}
 8
 9export async function inRollout(percentFlag) {
10  const pct = await flag(percentFlag);
11  const { rolloutSeed } = await chrome.storage.local.get("rolloutSeed");   // random 0..99, set on install
12  return rolloutSeed < pct;
13}

Execution context: any extension context; content scripts can read chrome.storage.local by default. A random bucket chosen once per install gives each user a stable position in every rollout: raising summariserPercent from 10 to 25 keeps the first 10% enabled and adds the next 15%. The seed never leaves the device. IS_DEV is a build-time constant so overrides cannot be activated in store builds.

A staged rollout planPercentage of installs with the new summariser enabled at each stage of a rollout over two weeks, increasing as error rates stay flat.Day 11 % of instal…Day 35 % of instal…Day 625 % of insta…Day 1050 % of insta…Day 14100 % of inst…
Each raise keeps earlier users enabled because the per-install bucket is stable.

6. React to changes everywhere

1// content script
2chrome.storage.onChanged.addListener(async (changes, area) => {
3  if (area !== "local" || !changes.flagsDoc) return;
4  const disabled = changes.flagsDoc.newValue?.flags?.disabledOnHosts ?? [];
5  if (disabled.includes(location.hostname)) teardownAll();
6});

Execution context: a content script. Storage change events reach every context, so a kill switch takes effect in open tabs without a reload. Popups and side panels read flags when they open and can listen the same way.

Common mistakes

  • Flags that carry behaviour. A document with selectors plus actions, or expressions to evaluate, is remote code in disguise. Keep values typed and dumb.
  • Unsafe defaults. A new feature defaulting to “on” ships to everyone the moment the flags fetch fails.
  • Re-rolling buckets. Computing Math.random() < pct on each check flips users in and out of a feature. Use a stored seed.
  • Unreviewed flag changes. A flags file edited directly in production is a deployment without review.
  • No removal plan. Flags accumulate. Remove a flag and its dead code path once a rollout is complete.

Cross-browser variation

  • Chrome / Edge: alarm refresh at 30 minutes is well above the one-minute floor; storage.onChanged reaches content scripts.
  • Firefox: same APIs and behaviour. AMO reviewers read flag-handling code; a small validated document passes comfortably.
  • Safari: alarms may be delayed while the system is idle; refresh also when an extension page opens so users see current flags.

Verification

  1. Publish a flags document that enables a feature for 100%; within the refresh interval, confirm await flag("newSummariser") returns true.
  2. Publish a malformed value ("summariserPercent": "lots") and confirm the flag keeps its previous valid value.
  3. Serve an older version and confirm the cached document is not replaced.
  4. Add the current site to disabledOnHosts and confirm the content script tears down in an open tab.

FAQ

Does a flags document need store review?

No — it is data. Reviewers check that the code handling it cannot execute it. Disclose in the listing that the extension fetches configuration.

Should I use a commercial flag service?

Only through a plain HTTP API that returns values, called from the worker. Avoid SDKs that load scripts or need window.

Can flags target users individually?

Avoid it. Per-user targeting requires identifiers on the server. Percentage rollouts with a local seed cover most needs without them.

Other Testing, Debugging & Performance Optimization Resources