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.
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.
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.
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.
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() < pcton 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.onChangedreaches 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
- Publish a flags document that enables a feature for 100%; within the refresh interval, confirm
await flag("newSummariser")returnstrue. - Publish a malformed value (
"summariserPercent": "lots") and confirm the flag keeps its previous valid value. - Serve an older
versionand confirm the cached document is not replaced. - Add the current site to
disabledOnHostsand 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.
Related
- Running A/B tests in an extension — flags plus measurement.
- Safely using remote config without remote code — the security rules for any remote data.
- Staged rollouts in the Chrome Web Store — the store’s own rollout mechanism.
- Usage analytics and feature flags — the parent topic.