MonkeyLoader All articles
Infrastructure

Roll Your Own Feature Flags: A Two-Person Team's Case for Ditching the Flag SaaS

MonkeyLoader
Roll Your Own Feature Flags: A Two-Person Team's Case for Ditching the Flag SaaS

Photo: Btrs, CC BY-SA 4.0, via Wikimedia Commons

Feature flag platforms are one of those categories where the trial is delightful and the renewal is grim. You start with a free tier, add targeting rules, discover per-MTU billing, and then somebody in finance asks why a two-person team is paying four figures a year to decide whether a checkout banner renders.

There is a second issue, quieter but more interesting: most of these platforms want you to send user identifiers, plan tiers, company names and whatever else you are targeting on, out to a third party, usually in the United States. For a UK product handling customer records, that is a data protection conversation you probably did not budget time for.

This is the argument for building your own. Not because SaaS is bad, but because for a small team the feature flag service is genuinely simple — and the version you control can be boring, cheap and defensible in front of a regulator.

What a flag service is actually doing

Strip away the dashboards and the experiment statistics and the thing has four jobs. Store a set of flag definitions. Evaluate them against a user context. Distribute the result to your apps quickly. Let a human flip something off in a hurry.

Everything else — experimentation analysis, approval workflows, multi-environment promotion — is nice to have at 200 engineers and mostly ceremony at two.

The architecture that survives a small team

Three moving parts. Flag definitions live in a Postgres table (or even a YAML file in the repo, more on that later). A publish step compiles those definitions into a single JSON snapshot with a version number and an HMAC signature. That snapshot sits behind a CDN with a long cache lifetime and an ETag, and every app — browser or server — pulls it, caches it, and evaluates flags locally.

Local evaluation matters. It means a flag check costs a function call, not an HTTP round trip, and it means a hundred toggles on a page do not turn into a hundred requests. The snapshot gets fetched once on boot, then polled every minute or so from the edge cache, which almost never touches your origin.

The payload contains flag definitions only. No user list, no emails, no account IDs. Targeting attributes are evaluated on your side, against data you already hold. That single design decision is worth more than any data processing agreement.

UK GDPR: keep the data boring

If you are a UK controller, the least painful position is the one where your flag service never processes personal data at all. A snapshot of flags is not personal data. There is no subject, no identifier, no linkage.

Sometimes you cannot avoid it — you want to roll out a feature to specific accounts, and the app needs to know which. Even then, minimise. Hash the identifier with a per-flag salt before it goes anywhere near a rule, and keep the salt out of the snapshot:

import { createHash } from 'node:crypto';

export function bucket(userId, flagKey, salt, buckets = 100) {
  const digest = createHash('sha256')
    .update(`${flagKey}:${salt}:${userId}`)
    .digest('hex');
  return parseInt(digest.slice(0, 8), 16) % buckets;
}

A percentage rollout is then just bucket(...) < rolloutPercent. Deterministic, no storage, no identifier leaving your perimeter in a readable form.

Beyond that, the usual hygiene applies. Host in a UK region if you can — London region instances are widely available and remove an entire transfer-mechanism discussion. If you self-host, your processor list does not grow at all. Write it into your record of processing activities, do a short legitimate interests assessment for the operational logging, turn request logging off by default, and set a retention window for the audit table rather than letting it grow until the next migration.

If you do use a third-party platform, use its UK or EU region and sign the right addendum. If you build your own, this paragraph is mostly a note to your future self that you did the easy thing.

The kill switch is the entire point

Slow rollouts are a convenience. Being able to turn off payments on a Friday evening is the reason the system exists.

Design the kill switch so it works when everything else does not. Concretely: serve the snapshot from the CDN with stale-while-revalidate measured in hours, so an origin outage does not stop clients getting the last good version. Cache the last successful snapshot in the client. And hard-code a safe default for every critical switch in the application itself, so a failed fetch degrades to the known-safe state rather than to whatever the code happens to do.

Then test it. Once a quarter, deliberately break the snapshot endpoint in staging, confirm the app falls back correctly, and confirm the kill switch still flips. You will feel faintly ridiculous doing it, right until the day you are not.

A minimal implementation

Honestly, this is smaller than people expect. A table, an endpoint, a compile script.

create table flags (
  key text primary key,
  enabled boolean not null default false,
  rollout int not null default 0,
  description text,
  owner text,
  expires_on date
);

The publish script selects the rows, increments a version, signs the JSON, and writes it to object storage. The endpoint just serves that file with an ETag. The client fetches it on boot, stores the version in memory, and polls with an If-None-Match header, which is a 304 most of the time and costs nothing.

You can skip the admin UI entirely for a long while. Edit the SQL, or better, keep flags in a YAML file in the repo so every change goes through a pull request and you get review and history for free. That is an unglamorous workflow that quietly prevents the classic 5pm flag flip with no audit trail.

The operational bits that actually bite

Name things consistently — team.area.feature — and keep a description column that says what the flag does and who owns it. Six months from now, enable_new_thing_2 will mean nothing to anyone.

Then deal with flag debt. Give every flag an expiry date and add a test that fails the build when a flag is past it. Without that, your codebase becomes a museum of half-finished rollouts, and every future change requires reasoning about four historical branches nobody remembers writing.

Finally, alert on snapshot staleness. If clients have not received a fresh snapshot in over an hour, someone should know before a customer does.

When to just pay the bill

If you need proper experiment statistics with significance testing, a warehouse sync, and self-service targeting for a non-technical product team, buy the platform. Building that is a project, not an afternoon.

But if what you actually want is percentage rollouts, a kill switch and a dashboard you can defend in a data protection review, roll your own. It is a few hundred lines, it costs you a small container and a database, and the only person who can raise the price is you.

All Articles

Related Articles

The Wrapper, the Pin and the Kill Switch: Extending Vendor Loaders You Cannot Fork

The Wrapper, the Pin and the Kill Switch: Extending Vendor Loaders You Cannot Fork

The Long Weekend Release Drill: Ship on Friday, Sleep on Monday

The Long Weekend Release Drill: Ship on Friday, Sleep on Monday

Forty Lines and a Promise: Swapping a Tag Manager for a Consent-Aware Script Loader

Forty Lines and a Promise: Swapping a Tag Manager for a Consent-Aware Script Loader