Feature flags
flagsTurn a feature on for some users, a market or a share of users, and change it while the app runs. Every instance gives the same answer for the same user.
flagsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
flags turns a feature on for some users, a market or a percentage of everyone, and you change it while the app runs. Reach for it for gradual rollouts, kill switches and per-market features.
At a glance
flags:
driver: configimport { flags } from "mainsdk";
if (flags.enabled("new-checkout", { user: user.id })) { // synchronous, from memory
return renderNewCheckout();
}app/flags.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { MemoryConfigStore } from "mainsdk";
describe("new-checkout", () => {
it("is on for India and off elsewhere", () => {
const store = new MemoryConfigStore();
store.set("flags.new-checkout", { enabled: true, rollout: 0, rules: [{ attr: "market", in: ["in"], serve: true }] });
const app = createTestApp({ yaml: "schema: 1\nproject: prj_test\nconfig:\n driver: memory", configDriver: store });
assert.equal(app.flags.enabled("new-checkout", { user: "u1", attrs: { market: "in" } }), true);
assert.equal(app.flags.enabled("new-checkout", { user: "u1", attrs: { market: "us" } }), false);
});
});Drivers
4 drivers. Select one to see its line in main.yaml.
flags:
driver: configChange this one line to swap providers; your calls stay the same.Config store integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-FLAGS-0001 | RuntimeError responses and logs | A flag was evaluated with no definition in config; the fallback was served, logged once per name |
MAIN-FLAGS-0002 | RuntimeError responses and logs | A pushed flag definition is invalid; it was refused and the last good definition kept |
MAIN-FLAGS-0003 | RuntimeError responses and logs | A rule served a value of a different type from the fallback, or enabled got a non-boolean; the fallback was served, logged once per name |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Enabled and value from the config store | Phase 1 | 0.3 | flags#flags-eval |
| Stable percentage rollout by user | Phase 1 | 0.3 | flags#flags-rollout |
| Live updates and refusal of invalid definitions | Phase 1 | 0.3 | flags#flags-live |
| Target by plan | Phase 1 | 0.3 | entitlements#limits-flags |
Quick start
Define the flag in your config store, under flags.<name>. With the files driver, config/flags.json holds it:
{ "new-checkout": { "enabled": true, "rollout": 25 } }flags:
driver: configimport { auth, flags } from "mainsdk";
export async function GET(req: Request) {
const session = await auth.require(req);
const useNew = flags.enabled("new-checkout", { user: session.user }); // synchronous: no await
return Response.json({ checkout: useNew ? "new" : "classic" });
}Configuration
flags:
driver: config # config (default) | openfeature (planned)config is the only driver today. A flag's definition is the config value at flags.<name>:
{
"enabled": true, // required; false turns the flag off for everyone, before any rule
"rollout": 25, // percent of users, 0 to 100, up to two decimals; default 100
"salt": "new-checkout", // optional; default is the flag name. Change it to reshuffle who is in
"rules": [ // optional; checked in order, first match wins, before rollout
{ "user": ["u_123"], "serve": true },
{ "attr": "market", "in": ["in"], "serve": true },
{ "attr": "plan", "notIn": ["enterprise"], "serve": false }
],
"value": "blue" // optional; what flags.value() returns when the flag is on
}What you get
A flag is a thin layer on config: its definition lives in the same store, arrives through the same change stream, passes the same checks and is audited the same way. flags.enabled() and flags.value() are synchronous and read memory, so a check makes no network call and waits on nothing. Every instance gives the same answer for the same user, so a user never flips between old and new as requests land on different instances.
How a check decides
- No definition: the flag is off, and
flags.value()returns the fallback. The name is logged once. enabled: false: off, whatever the rules say.- Rules, in order. The first rule that matches serves its
servevalue. A rule whose attribute is missing from the context does not match. - Rollout. The user's bucket is the first four bytes of the SHA-256 of
<salt>:<user>, modulo 10,000. The flag is on when the bucket is underrollout× 100. With no user, the flag is on only at 100. - On:
enabledreturnstrue, andvaluereturns the definition'svalueor the fallback. Off:falseand the fallback.
The bucket depends only on the salt and the user id. Raising rollout keeps everyone who was already on. Changing the salt moves everyone at once.
Examples
Turn a feature on for one market first
{ "enabled": true, "rollout": 5, "rules": [{ "attr": "market", "in": ["in"], "serve": true }] }import { flags } from "mainsdk";
export function checkoutFor(userId: string, market: string) {
return flags.enabled("new-checkout", { user: userId, attrs: { market } }) ? "new" : "classic";
}India gets the new checkout first, then 5% of everyone else. Raise rollout to 100 when it is ready; nobody who was on drops off.
Kill switch in an API route
import { auth, flags } from "mainsdk";
export async function POST(req: Request) {
const session = await auth.require(req);
if (!flags.enabled("imports", { user: session.user })) {
return Response.json({ error: "Imports are paused" }, { status: 503 });
}
return new Response(null, { status: 202 });
}Set flags.imports to { "enabled": false } in your config store and every instance stops accepting imports, with no deploy.
Read a typed value
A flag can carry a value. flags.value() returns it when the flag is on, and the fallback otherwise. The fallback sets the type.
import { flags } from "mainsdk";
export function exportRowLimit(userId: string) {
return flags.value("export.row-limit", 500, { user: userId });
}Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-FLAGS-0001 | A flag was checked with no definition; the fallback was served. Logged once per name | Define flags.<name> in the config store, or check the right name |
MAIN-FLAGS-0002 | A pushed definition is invalid. It is refused, the last good definition stays, and the refusal is audited | Fix the definition: enabled a boolean, rollout from 0 to 100 with at most two decimals, and each rule with exactly one of user, in or notIn |
MAIN-FLAGS-0003 | A rule serves a value of another type from the fallback, or enabled gets a non-boolean. The fallback is served. Logged once per name | Serve a value of the same type as the fallback |
Evaluation writes no audit record per check and logs no user id or attribute. A flag log line carries only the flag name. A flag that is refused keeps its last good definition; see Config.
Swapping drivers
Flags use the config driver you already run, so changing config.driver moves the flag definitions with it. The openfeature driver is planned: it would keep definitions in LaunchDarkly, Flagsmith, Unleash, PostHog or Vercel Flags. Your flags.enabled() and flags.value() calls stay the same.
Testing
Share a MemoryConfigStore with the test app, set definitions at flags.<name>, and assert each answer with app.flags.enabled() or app.flags.value(). Evaluation is synchronous, so only a change needs await app.live.refresh().
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { MemoryConfigStore } from "mainsdk";
describe("new-checkout", () => {
it("is on for India and off elsewhere", () => {
const store = new MemoryConfigStore();
store.set("flags.new-checkout", { enabled: true, rollout: 0, rules: [{ attr: "market", in: ["in"], serve: true }] });
const app = createTestApp({ yaml: "schema: 1\nproject: prj_test\nconfig:\n driver: memory", configDriver: store });
assert.equal(app.flags.enabled("new-checkout", { user: "u1", attrs: { market: "in" } }), true);
assert.equal(app.flags.enabled("new-checkout", { user: "u1", attrs: { market: "us" } }), false);
});
});See the Testing kit.
Security and privacy
- Evaluation logs only the flag name. User ids and attributes are never written to a log, an audit record or a provider.
- Rollout buckets come from a hash of the salt and the user id, so no list of users is stored.
- Definitions pass the same checks and audit as any config change.
FAQ
Can a flag call a remote service? No. Evaluation reads memory only: no network call, disk read or audit record per check.
Why not await flags.enabled()? It is synchronous. Awaiting it does nothing but add a tick.
How is this different from config? Config holds typed values for the whole app. Flags are on or off, or a value, per user, with rollouts and rules.
Target by plan
Inside a tenant, the context also carries the tenant's tenant id, tenant_kind, region and plan as attributes (attributes you pass win). A rule such as { "attr": "plan", "in": ["pro"], "serve": true } turns a feature on for pro and leaves it off for free without a code change. The plan comes from entitlements. For a feature that is part of a plan, prefer entitlements.require(): it refuses with an upgrade hint, where a flag only hides.
Next
- Runtime settings: the store flags are kept in.
- Change config without a deploy: rollouts and watchers, step by step.
- Testing kit: the memory driver and assertions.
Was this page helpful?