Feature flags (feature-flags)

Ship code dark and turn it on when you are ready: kill switches, gradual rollouts, per user and per role targeting, and weighted variants for experiments. Flags live in MongoDB, are evaluated in memory (ctx.flags.enabled('new-checkout') is synchronous) and give every user the same answer on every instance. Admins change them through an API, frontends read theirs from GET /api/flags, routes can be gated with requireFlag(), and devtools lists them.

Shell
npx calm add feature-flags
Adds
Filesapp/blocks/feature-flags/index.js, evaluate.js (the rules), gate.js (requireFlag)
Environmentnone
RoutesGET /api/flags, GET and POST /api/admin/flags, GET, PATCH and DELETE /api/admin/flags/:key
Collectioncalm_flags

A flag#

JSON
{
  "key": "new-checkout",
  "description": "One page checkout",
  "enabled": true,
  "rollout": 20,
  "users": ["6650c0ffee0000000000abcd"],
  "roles": ["beta"],
  "variants": { "control": 50, "one-page": 50 },
  "clientVisible": true
}

A flag is on for a caller when:

  1. enabled is true. It is the kill switch: false turns the flag off for everyone, targeting included.
  2. And their user id is in users, or they have one of the roles, or they fall inside rollout.

rollout is a percentage. Each user gets a bucket from 0 to 99, a hash of the flag key and their id, and the flag is on when the bucket is below rollout. There is no randomness and nothing stored per user: the same user gets the same answer on every request and every instance. Raising the rollout from 20 to 50 keeps the first 20% and adds 30% more, so nobody flips back and forth. Each flag hashes differently, so the same 20% of users are not always first.

Visitors who are not signed in are off, unless rollout is 100 or users lists their anonymous id. If your frontend sends a stable random id in the X-Anonymous-Id header (letters, digits, ., _, : and -, up to 128 characters), visitors are bucketed by it like users. Signed-in users are always bucketed by their user id.

In your code#

ctx.flags is on every request context (handlers, and the hooks and services that receive ctx):

JavaScript
r.post('/orders', { policy: 'authenticated' }, async (ctx) => {
  if (ctx.flags.enabled('new-checkout')) return ctx.services.order.checkoutV2(ctx.body, ctx);
  return ctx.services.order.checkout(ctx.body, ctx);
});

const layout = ctx.flags.variant('pricing-page'); // 'control' | 'one-page' | null

variant() is null when the flag is off for the caller, when it has no variants, or when the caller has no id (a visitor without X-Anonymous-Id). Variants are weighted ({ "control": 50, "one-page": 50 }, whole numbers) and hashed separately from the rollout, so a user keeps their variant when the rollout changes. Changing the weights moves some users to another variant.

Unknown flags are off. In development, the first check of a flag that does not exist logs a warning, which catches typos.

Outside requests (jobs, event listeners, scripts), use the same evaluation through app.ext.flags:

JavaScript
const flags = app.ext.flags;
await flags.isEnabled('new-checkout', { user });      // a user record, or { userId, roles }
await flags.variant('pricing-page', { anonymousId });
const forUser = await flags.evaluate({ user });       // then forUser.enabled(...) synchronously

Gating routes with requireFlag#

JavaScript
import { requireFlag } from '#app/blocks/feature-flags/index.js';

export default (r) => {
  r.post('/checkout/v2', { policy: [requireFlag('new-checkout'), 'authenticated'] }, checkoutV2);
  r.get('/labs', { policy: requireFlag('labs') }, labs);
};

While the flag is off for the caller, the route answers 404 Route not found, as if it did not exist. When it is on, the rest of the policy decides as usual: [requireFlag('new-checkout'), 'authenticated'] still needs a signed-in user, and [requireFlag('notes'), 'owner'] still limits users to their own records. A policy made only of gates (requireFlag('labs')) lets in whoever the flag is on for, visitors included when it is on for them.

Gates work in resource policies too:

JavaScript
const notes = requireFlag('notes');

export default resource('note', {
  fields: { text: f.string().required(), author: f.ref('user').owner() },
  policy: { list: [notes, 'owner'], read: [notes, 'owner'], create: [notes, 'authenticated'], update: [notes, 'owner'], delete: [notes, 'owner'] },
});

How it works: a policy list means "any of" in CalmAPI, so an ordinary policy function could only widen access. requireFlag() returns a policy function marked as a gate. The block's guard finds gates in the route's policy and answers 404 before the policy runs; inside the policy, a gate never grants access on its own. Routes show the gate as flag:new-checkout in calm routes and devtools. Gates are for route and resource action policies; in field rules (visibleTo, writableBy) they are always false.

Declaring flags in code#

Flags your code relies on can be declared in calm.config.js. They are created on start when they do not exist, and never touched again: once a flag exists, the admin API owns it, so a deploy never undoes an admin's change.

JavaScript
export default defineConfig({
  blocks: {
    'feature-flags': {
      flags: {
        'new-checkout': { description: 'One page checkout', rollout: 0 },
        'pricing-page': { description: 'Pricing experiment', rollout: 100, variants: { control: 50, 'one-page': 50 }, clientVisible: true },
      },
    },
  },
});

Declared flags start with enabled: true and whatever you set: a flag declared with rollout: 0 is live only for the users and roles you target. Invalid declarations stop the boot with a readable error. A declared flag deleted through the API comes back, with its declared values, on the next start.

Admin API#

Every route needs the admin role.

HTTP
GET    /api/admin/flags                 every flag
GET    /api/admin/flags/new-checkout
POST   /api/admin/flags                 { "key": "new-checkout", "rollout": 10 }        -> 201
PATCH  /api/admin/flags/new-checkout    { "rollout": 50, "roles": ["beta"] }
DELETE /api/admin/flags/new-checkout                                                   -> 204

PATCH changes only the fields you send, and null resets a field ("variants": null removes the variants). Keys cannot change, and a key that is taken answers 409. Every change records updatedBy and emits flag.changed.

Field
keyLetters, digits, ., - and _, up to 100, starting with a letter or a digit
descriptionUp to 500 characters
enabledThe kill switch (default true)
rollout0 to 100 (default 0)
users, rolesUser ids (or anonymous ids) and roles that always get the flag
variants{ name: weight }, up to 20, whole weights
clientVisibleInclude it in GET /api/flags (default false)

For frontends#

GET /api/flags returns the flags marked clientVisible, already evaluated for the caller, and nothing about the others:

JSON
{ "data": { "new-checkout": { "enabled": true, "variant": null }, "pricing-page": { "enabled": true, "variant": "one-page" } } }

Send the session token for signed-in users and X-Anonymous-Id for visitors. The response is marked Cache-Control: private, no-store. Flags without clientVisible stay on the server, so internal flag names and targeting never reach the browser.

Settings and caching#

JavaScript
export default defineConfig({ blocks: { 'feature-flags': { cacheTtlMs: 2000 } } });
OptionDefault
cacheTtlMs5000How long a process reuses the flags it read. 0 reads them on every request.
flags{}Flags created on start when missing (see Declaring flags in code)

Each process reads all flags at most once per cacheTtlMs and evaluates from memory. Changes made through the admin API (or app.ext.flags) apply at once in the process that made them, and within cacheTtlMs in the others. After writing to calm_flags directly, call app.ext.flags.invalidate() or wait for the TTL. If MongoDB cannot be read, the last known flags keep being served, or every flag is off if they were never read.

Events and devtools#

flag.changed is emitted for every create, update and delete, through the API or from declared flags:

JavaScript
calm.on('flag.changed', ({ key, action, flag, previous, user }) => {
  calm.log.info(`${user?.name ?? 'Deploy'} ${action} ${key}: rollout ${previous?.rollout ?? 0}% to ${flag?.rollout ?? 0}%`);
});

In development, open /_calm and pick Feature flags to see every flag with its rollout, targeting, variants and last change.

Testing#

Declare the flags a test needs through the config of the test app, or create them with app.ext.flags:

JavaScript
const t = await createTestApp({
  root: join(import.meta.dirname, '..'),
  config: { blocks: { 'feature-flags': { cacheTtlMs: 0, flags: { labs: { rollout: 0, roles: ['beta'] } } } } },
});

test('labs is only there for beta users', async () => {
  const beta = await t.signup({ roles: ['user', 'beta'] });
  const other = await t.signup();
  assert.equal((await t.get('/labs', beta)).status, 200);
  assert.equal((await t.get('/labs', other)).status, 404);

  await t.app.ext.flags.update('labs', { rollout: 100 });
  assert.equal((await t.get('/labs', other)).status, 200);
});