DEV Community

Cover image for Feature Flags for Multi-Tenant SaaS: Tenant Rollouts
Muhammad Ali Mughal
Muhammad Ali Mughal

Posted on

Feature Flags for Multi-Tenant SaaS: Tenant Rollouts

In a B2B SaaS product, the user is the wrong unit for a feature flag. Your customer is the tenant: one company, one contract, one admin who files the ticket when the invoice screen changes under 40 of their staff mid-month. If your flag system buckets by user ID, half of that company sees the new screen and half sees the old one, and your support team spends a week explaining a bug that is really a rollout design decision.

I run Loop Origin, where we build and run multi-tenant platforms, and staged rollouts are one of the first things I put in place before a product's tenant count grows. I wrote this with help from AI tools and checked every command and claim myself. The code and outputs below ran on my machine today.

How should you roll out features in a multi-tenant SaaS?

Direct answer: Evaluate flags per tenant, not per user, in a fixed order: kill switch, explicit tenant override, plan entitlement, then a deterministic percentage ring. Hash the flag key with the tenant ID so cohorts are sticky as you ramp. Cache evaluations briefly, audit every change, and give each flag an owner and an expiry date.

Release flags, entitlements and kill switches are different things

Most flag messes start because three different jobs share one boolean:

Flag type Question it answers Lifetime Who changes it Example
Release flag Is this unfinished or risky code path on for this tenant? Days to weeks, then deleted Engineering new-invoice-engine
Entitlement Has this tenant paid for this capability? Permanent, part of billing Product and billing sso-saml on the Enterprise plan
Kill switch Can we turn this off for everyone in seconds? Permanent for risky integrations On-call engineer disable-bulk-export
Ops / config flag How should this behave right now? Long-lived Engineering Batch size, rate limit, provider choice

Keep entitlements in your billing domain, not your flag tool. A release flag that quietly becomes "the Enterprise feature" turns into a revenue bug the day someone deletes it.

The evaluation order

Order matters because it decides who wins when rules conflict:

1. Kill switch on?                 -> OFF for everyone, stop
2. Explicit tenant override?       -> use it (design partners, a tenant who asked to wait)
3. Plan entitlement required?      -> OFF if the tenant's plan lacks it
4. Percentage ring                 -> ON if bucket(flag, tenant) < ring%
5. Default                         -> OFF
Enter fullscreen mode Exit fullscreen mode

A minimal Postgres shape that supports this:

CREATE TABLE feature_flags (
  key            text PRIMARY KEY,
  kind           text NOT NULL CHECK (kind IN ('release','kill','ops')),
  rollout_pct    numeric(5,2) NOT NULL DEFAULT 0,  -- 0.00 to 100.00
  required_plan  text,                             -- NULL = any plan
  killed         boolean NOT NULL DEFAULT false,
  owner          text NOT NULL,
  expires_on     date,                             -- required for kind='release'
  updated_at     timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE feature_flag_overrides (
  flag_key   text REFERENCES feature_flags(key) ON DELETE CASCADE,
  tenant_id  bigint NOT NULL,
  enabled    boolean NOT NULL,
  reason     text NOT NULL,
  PRIMARY KEY (flag_key, tenant_id)
);
Enter fullscreen mode Exit fullscreen mode

The owner and expires_on columns are not decoration. They are what lets you find and delete stale release flags later.

Deterministic, sticky bucketing

Never use random() for a rollout. A tenant must get the same answer on every request, on every server, and must stay in the cohort as you ramp from 10% to 25%. Hashing flag:tenant gives you all three:

import { createHash } from 'node:crypto';

export function bucket(flag, tenantId) {
  const h = createHash('sha256').update(`${flag}:${tenantId}`).digest();
  return h.readUInt32BE(0) % 10000;          // 0..9999, i.e. 0.01% steps
}
export const enabled = (flag, tenantId, pct) => bucket(flag, tenantId) < pct * 100;
Enter fullscreen mode Exit fullscreen mode

I ran it over 10,000 synthetic tenants:

10% cohort size: 995
25% cohort size: 2458
50% cohort size: 4923
10% tenants still on at 25%: true
25% tenants still on at 50%: true
overlap between two 10% flags: 98 (9.8% of cohort)
Enter fullscreen mode Exit fullscreen mode

The cohorts land close to target, every tenant already on stays on as you ramp, and two different flags at 10% overlap at about 10%, which is what independent cohorts should do. Salting with the flag key matters: if you hash only the tenant ID, the same unlucky tenants are first in line for every risky release.

The cost is small. One million evaluations took 1,350 ms in a single Node 24 process on this box, about 1.35 µs each. That is cheap enough to evaluate per request, so caching is about reading flag definitions, not the hash.

Rollout rings for B2B

Ring Who Typical size Exit criteria before the next ring
0 Your own internal tenant 1 Core flows pass, no new error types
1 Design partners who opted in 3 to 10 Explicit feedback, support tickets triaged
2 Percentage of self-serve tenants 10% Error rate and key metrics within your agreed threshold for several days
3 Wider self-serve 50% Same, plus performance under real load
4 Everyone, enterprise last 100% Enterprise admins notified per contract terms
Cleanup Delete the flag and the old path n/a Old code removed in one PR

Enterprise tenants go last on purpose. They are most likely to have change-notice clauses, trained staff and internal documentation that a silent UI change breaks.

A railway track switch where one line splits into two, representing a flag routing tenants onto a new code path
AI-generated image.

Build, Pennant, open source or SaaS?

Option What you run Pricing model Best fit Watch out for
Hand-built (tables above + cache) Postgres, Redis Your engineering time Few flags, one codebase No UI or audit log unless you build them
Laravel Pennant Your Laravel app and database Free, open source Laravel monoliths Resolved values are stored per scope, so changing a rule needs a purge
Self-hosted open source (Unleash, Flagsmith, GrowthBook) A flag service and its database Free core, paid tiers Several services, need a UI Another service to secure and upgrade
Hosted flag platform Nothing Subscription, often tied to seats or usage Many teams, experimentation Vendor cost and a runtime dependency

In Laravel, Pennant makes the tenant the scope directly:

use Laravel\Pennant\Feature;

Feature::define('new-invoice-engine', function (Team $team) {
    if ($team->isInternal()) return true;
    return crc32("new-invoice-engine:{$team->id}") % 100 < 10;  // 10% ring
});

if (Feature::for($team)->active('new-invoice-engine')) { /* new path */ }
Enter fullscreen mode Exit fullscreen mode

With Pennant's database driver, the first resolved value is stored and reused. That makes it sticky, but raising 10% to 25% in code does not move tenants already stored as off until you run php artisan pennant:purge new-invoice-engine. Plan that step into the ramp.

Failure modes I design against

  • Cache split-brain. One node caches a flag for 5 minutes, another reads it fresh, and a tenant flips between versions on every request. Keep TTLs short, around 10 to 30 seconds, or push invalidations through Redis pub/sub.
  • Flags inside migrations. A flag cannot roll back a schema change. Ship schema changes expand-and-contract first, as in my zero-downtime database migrations guide, and flag only the code paths.
  • Background jobs ignore the flag. Queued jobs evaluate when they run, not when they were dispatched. Pass the evaluated decision into the job payload.
  • Cross-tenant leaks through shared caches. If a flagged path changes a cache key format, key it by tenant as well. My multi-tenant SaaS database architecture piece covers isolation in more depth.
  • Flag debt. Every flag doubles the paths to test. A weekly report of release flags past expires_on keeps the count honest.

The rollout checklist

  1. Classify the flag: release, entitlement, kill switch or ops.
  2. Scope it to the tenant; fall back to user only for per-user preferences.
  3. Set an owner and an expiry date before merging.
  4. Write tests for both paths and run them in CI.
  5. Define the metrics and thresholds that gate each ring before you start.
  6. Log every flag change with who, when, old value and new value.
  7. Ramp through the rings; notify enterprise admins before their turn.
  8. Delete the flag and the dead path within one sprint of reaching 100%.

If you are planning a multi-tenant product and want rollouts designed in from day one, this is the kind of custom web application work my team does.

Frequently asked questions

Should feature flags target users or tenants in B2B SaaS?

Tenants, for anything that changes shared workflows, data or UI. Per-user targeting splits a single customer into two experiences. Keep user-level flags for personal preferences and internal staff previews.

How do I make percentage rollouts sticky?

Hash the flag key and tenant ID into a fixed bucket and compare it with the percentage. The same tenant always lands in the same bucket, so raising the percentage only adds tenants.

Are feature flags the same as plan entitlements?

No. Entitlements are permanent billing rules. Release flags are temporary and should be deleted. Mixing them makes it risky to clean up flags.

How short should a flag cache TTL be?

Short enough that a kill switch takes effect within your incident target, often 10 to 30 seconds, or use push invalidation so changes apply almost immediately.

When should I delete a release flag?

Once it has been at 100% long enough to be confident, usually within one sprint. Remove both the flag and the old code path in the same pull request.

Do I need a paid feature flag service?

Not at the start. A table, a cache and an audit log cover a single product. Move to a dedicated service when several teams or services need a UI, experiments or approvals.


Follow & Connect

Top comments (0)