cd /news/developer-tools/feature-flags-for-multi-tenant-saas-… · home › topics › developer-tools › article
[ARTICLE · art-163117] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

Feature Flags for Multi-Tenant SaaS: Tenant Rollouts

A developer at Loop Origin described a tenant-level feature-flag design for multi-tenant B2B SaaS, arguing that flags should be evaluated per tenant rather than per user so an entire company sees a consistent experience during staged rollouts. The approach evaluates flags in a fixed order—kill switch, explicit tenant override, plan entitlement, then a deterministic percentage ring—using a SHA-256 hash of the flag key and tenant ID to keep cohorts sticky as a rollout ramps. The writeup includes a Postgres schema with owner and expiry columns and a Node.js bucketing function tested over 10,000 synthetic tenants.

by read7 min views4 publishedNov 3, 2026

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.

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.

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.

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

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)
);

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

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;

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)

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.

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.

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 */ }

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.

expires_on keeps the count honest. 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.

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.

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.

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

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.

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.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @loop origin 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/feature-flags-for-mu…] indexed:0 read:7min 2026-11-03 · —