Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iuriiย ships.

Iurii Rogulia, IT partner for business & fractional CTO. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland ๐Ÿ‡ซ๐Ÿ‡ฎ

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. Feature Flags Without LaunchDarkly: A 100-Line Solution

Iuriiย Gates: Feature Flags Without LaunchDarkly: A 100-Line Solution

Deploy on Tuesday, release on Thursday. You don't need a SaaS to decouple the two โ€” you need about a hundred lines of code.

July 13, 2026ยท 9 min read

Feature flags without LaunchDarkly: a Postgres-backed, ~100-line system with deterministic percentage rollout, per-tenant overrides, and caching.

Stack

TypeScriptNode.js

Databases

PostgreSQL

Topics

SaaSFeature FlagsWeb Development
Feature Flags Without LaunchDarkly: A 100-Line Solution

On this page

  • What Feature Flags Actually Buy You
  • Why a SaaS Is Often the Wrong First Choice
  • The 100-Line Solution
  • The flags table
  • Deterministic percentage rollout
  • The evaluation function
  • Caching: stay off the database
  • When You Should Actually Buy LaunchDarkly
  • The static-config variant
  • Takeaways

You want to merge a half-finished checkout redesign into main without breaking checkout for everyone. You want to ship a risky billing change but keep a kill switch in case it misbehaves at 2am. You want to turn a new dashboard on for one beta customer and nobody else. The first instinct, reading the docs, is to reach for LaunchDarkly or Flagsmith or Split. But for a small team or an early-stage SaaS, feature flags without LaunchDarkly is not a compromise โ€” it's about a hundred lines of code you fully own.

This is the same kind of decision I keep helping founders make: which piece of infrastructure to actually buy, and which to build because the build is small and the buy is a recurring tax. Feature flags, for a team that has eight of them, land firmly on the build side. Let me show you why, and then show you the code.

What Feature Flags Actually Buy You

Strip away the marketing and a feature flag is one thing: a runtime switch that decides whether a piece of code runs, without redeploying. That single capability unlocks several distinct workflows, and it's worth being precise about them because they're often blurred together.

Decoupling deploy from release. Today, for most teams, deploying code and releasing a feature are the same event โ€” the moment the new bundle goes live, users get the new behaviour. Flags split those apart. You deploy the code dark on Tuesday, verify it's healthy in production, and flip it on for users on Thursday. The deploy is a low-stress engineering event; the release is a separate, deliberate product decision.

Kill switches for risky code. A new payment path, a rewritten search index, a third-party integration you don't fully trust yet โ€” wrap it in a flag and you have an off switch that doesn't require a rollback deploy. When something misbehaves, you flip the flag instead of reverting commits and waiting for CI. Mean-time-to-recovery drops from "however long a deploy takes" to "a database write."

Gradual percentage rollout. Instead of shipping a change to 100% of users at once, you turn it on for 5%, watch your error rates and latency, then 20%, then 50%, then everyone. If something breaks, it broke for 5% of traffic, not all of it. This is the feature that's genuinely fiddly to build correctly, and most of the technical interest in this article lives here.

Per-user and per-tenant targeting. Turn a feature on for one specific beta tenant, your own internal accounts, or everyone on the enterprise plan โ€” regardless of the rollout percentage. This is how you dogfood, how you run private betas, and how you honour "customer X explicitly asked to opt out."

Trunk-based development. You can merge unfinished work into main behind a flag that defaults to off. The code is in the codebase, getting integrated and built continuously, but it's inert until you decide otherwise. This kills long-lived feature branches and the merge hell that comes with them.

That's the value. Notice that none of it inherently requires a vendor.

Why a SaaS Is Often the Wrong First Choice

I want to be fair here, because LaunchDarkly is a genuinely good product and there's a point where it earns its price. But for an early-stage team, buying it first is usually backwards, for four concrete reasons.

Cost that scales with the wrong thing. Flag platforms price on seats and monthly active users โ€” the very numbers a growing product wants to grow. You start paying more precisely as you succeed, for a capability whose complexity didn't change. A team with eight flags is paying a per-MAU rate for infrastructure they could express in one database table.

A network dependency in your hot path. This is the one engineers underestimate. Every flag evaluation is conceptually a question โ€” "is feature X on for this user?" โ€” and a SaaS answers it either via a remote call or via an SDK that has to initialize, stream updates, and stay in sync. Their SDKs work hard to make this fast and local, but you've still introduced a third-party system into the path of rendering your pages. When their edge has a bad day, or the SDK fails to init, you need a sane fallback โ€” and now you're writing flag-evaluation fallback logic anyway.

Data-residency and privacy surface. To do per-user targeting, the platform needs to know about your users โ€” identifiers, attributes, sometimes more. For an EU product that's another processor in your data-flow diagram, another DPA to sign, another thing your privacy policy has to account for. Keeping flag evaluation inside your own Postgres sidesteps all of it.

Massively over-featured for where you are. Audit logs, approval workflows, multivariate experiments, twelve-attribute segmentation, a polished UI for non-engineers โ€” that's a lot of product. It's the right product for a 60-engineer org with PMs flipping flags. It's dead weight for a three-person team whose "flag UI" can be a SQL UPDATE.

None of these are dealbreakers forever. They're reasons not to start there.

The 100-Line Solution

Here's the whole design. A Postgres table holds the flags. A small evaluation function answers isEnabled(key, context). An in-memory cache keeps you off the database on the hot path. That's it.

I'm using Postgres-backed flags as the primary version on purpose, because the entire point of a flag is to flip it without a deploy. If your flags live in a TypeScript config file, changing one means a commit, a build, and a deploy โ€” which defeats the kill-switch and gradual-rollout use cases. A static config is fine for the simplest case (a permanent on/off toggle that rarely changes), and I'll note that variant at the end, but the database version is the one that earns its keep.

The flags table

CREATE TABLE feature_flags (
  key                 TEXT PRIMARY KEY,
  enabled             BOOLEAN     NOT NULL DEFAULT false,
  rollout_percentage  SMALLINT    NOT NULL DEFAULT 0
                        CHECK (rollout_percentage BETWEEN 0 AND 100),
  enabled_tenants     TEXT[]      NOT NULL DEFAULT '{}',
  disabled_tenants    TEXT[]      NOT NULL DEFAULT '{}',
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

The columns map directly to the use cases above. enabled is the master switch โ€” false means off for everyone, full stop, which is your kill switch. rollout_percentage drives gradual rollout. enabled_tenants and disabled_tenants are the targeting allow-lists: a tenant in enabled_tenants gets the feature regardless of percentage, and a tenant in disabled_tenants never gets it regardless of percentage. (For per-user rather than per-tenant targeting, the same columns hold user IDs โ€” the evaluation logic is identical; pick whichever identifier your product keys on.)

Deterministic percentage rollout

This is the part worth slowing down for. The naive way to roll a flag out to 20% of users is to roll a die per request:

// ANTI-PATTERN: do not do this โ€” recomputes per request
function isInRollout(percentage: number): boolean {
  return Math.random() * 100 < percentage;
}

That's broken in a way that's easy to miss. The same user gets a different answer on every request โ€” feature on, page reload, feature off, reload, feature on again. The UI flickers, sessions are inconsistent, and your error rates become impossible to attribute. A 20% rollout has to mean "a stable 20% of users always get it," not "every request has a 20% chance."

The fix is to make the decision a deterministic function of the user and the flag, with no randomness at request time. Hash flagKey + userId into a number in [0, 100) and compare it to the rollout percentage. Same user, same flag, same answer โ€” forever, until you change the percentage. This is the same deterministic-hashing trick I used to make a viral web toy wrong the same way every time: unpredictable to a human, perfectly reproducible from the input alone.

You need a fast, well-distributed hash โ€” not a cryptographic one, since this isn't a security boundary, just a bucketing function. FNV-1a is a good fit: tiny, fast, and spreads inputs evenly across the output range.

// FNV-1a, 32-bit. Small, fast, well-distributed โ€” not cryptographic.
function fnv1a(input: string): number {
  let hash = 0x811c9dc5; // FNV offset basis
  for (let i = 0; i < input.length; i++) {
    hash ^= input.charCodeAt(i);
    // 32-bit FNV prime multiply, kept in uint32 range
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash >>> 0;
}
 
// Map any string to a stable bucket in [0, 100).
function bucket(flagKey: string, userId: string): number {
  // Salt with the flag key so a user isn't always in the same
  // percentile across every flag โ€” otherwise the unlucky 5% of
  // user A's first rollout are the unlucky 5% of every rollout.
  return fnv1a(`${flagKey}:${userId}`) % 100;
}

The salting detail matters more than it looks. If you hash the user ID alone, the user who lands in bucket 3 is in bucket 3 for every flag โ€” so the same unlucky cohort is always first into every rollout, and your "20% of users" are always the same 20% of users across unrelated features. Mixing the flag key in re-shuffles the buckets per flag, so each rollout samples an independent slice.

The evaluation function

Now the whole decision, in the order the rules apply:

type FlagRecord = {
  key: string;
  enabled: boolean;
  rolloutPercentage: number;
  enabledTenants: string[];
  disabledTenants: string[];
};
 
type Context = {
  tenantId?: string; // or userId โ€” whatever you bucket on
};
 
function evaluate(flag: FlagRecord | undefined, ctx: Context): boolean {
  // Unknown flag โ†’ off. Fail closed, never crash on a typo'd key.
  if (!flag) return false;
 
  // Master kill switch wins over everything.
  if (!flag.enabled) return false;
 
  const id = ctx.tenantId;
 
  // Explicit overrides beat the percentage roll, both directions.
  if (id && flag.disabledTenants.includes(id)) return false;
  if (id && flag.enabledTenants.includes(id)) return true;
 
  // No identity to bucket on โ†’ treat as a plain on/off at 100%.
  if (!id) return flag.rolloutPercentage >= 100;
 
  // Deterministic percentage rollout.
  return bucket(flag.key, id) < flag.rolloutPercentage;
}

Read the order of the rules, because the order is the semantics. Kill switch first, then explicit per-tenant overrides (force-off beats force-on by convention โ€” the safer direction wins ties), then the percentage bucket. An unknown flag key returns false rather than throwing: a typo in a flag name should make the feature quietly stay off, never take down the request. That fail-closed default is deliberate, and it's the kind of small decision that separates a flag system you can trust from one that becomes its own source of incidents.

Caching: stay off the database

If evaluate hit Postgres on every flag check, you'd add a query to the hot path of every request โ€” exactly the network dependency I criticized the SaaS for. So you don't. Load all flags into memory once, refresh on an interval, and serve every evaluation from the in-memory snapshot.

import type { Pool } from "pg";
 
const REFRESH_MS = 30_000; // flips propagate within this window
 
export class FlagStore {
  private cache = new Map<string, FlagRecord>();
 
  constructor(private pool: Pool) {}
 
  async start(): Promise<void> {
    await this.refresh();
    // unref so the timer never keeps the process alive on shutdown
    setInterval(() => {
      this.refresh().catch((err) => console.error("flag refresh failed", err));
    }, REFRESH_MS).unref();
  }
 
  private async refresh(): Promise<void> {
    const { rows } = await this.pool.query<FlagRecord>(
      `SELECT key,
              enabled,
              rollout_percentage AS "rolloutPercentage",
              enabled_tenants    AS "enabledTenants",
              disabled_tenants   AS "disabledTenants"
       FROM feature_flags`
    );
    const next = new Map<string, FlagRecord>();
    for (const row of rows) next.set(row.key, row);
    this.cache = next; // atomic swap โ€” readers never see a half-built map
  }
 
  isEnabled(key: string, ctx: Context = {}): boolean {
    return evaluate(this.cache.get(key), ctx);
  }
}

Call flags.start() once at boot, then flags.isEnabled("new_checkout", { tenantId }) anywhere โ€” it's a synchronous map lookup plus an integer hash, with no I/O. Note the failure handling: a failed refresh logs and keeps serving the previous snapshot, so a transient database blip degrades to slightly-stale flags rather than an outage. And building the new map fully before swapping it in means a reader mid-refresh sees either the complete old state or the complete new state, never a partial one.

The honest tradeoff is right there in REFRESH_MS: a flag flip takes up to one refresh interval to propagate to every running instance. At 30 seconds, flipping a kill switch in SQL takes up to half a minute to fully take effect across your fleet. For most teams that's completely fine โ€” half a minute to disable a misbehaving feature is still dramatically faster than a rollback deploy. If you genuinely need sub-second propagation, that's a real reason to want something more, which is the next section. (You can also shorten the interval or add a LISTEN/NOTIFY push to invalidate on write โ€” but now you're adding lines, and the whole pitch was that this stays small.)

That's the system. The table, the hash, the evaluation function, and the cached store come to roughly a hundred lines, and you own every one of them.

Related service

MVP Development

Building an MVP and weighing build-versus-buy on every piece of infrastructure? Knowing which 100-line solution beats a subscription โ€” and which doesn't โ€” is exactly the kind of early call I help founders get right.

More about this service โ†’

When You Should Actually Buy LaunchDarkly

I'm not going to pretend the 100-line version scales to every org, because it doesn't, and pretending otherwise would be the kind of dishonesty that makes the rest of this article less trustworthy. There's a clear line where a SaaS earns its price. You're over it when:

  • Non-engineers need to flip flags themselves. The moment a PM or a marketing lead wants to turn a feature on without a developer running SQL, you need a real UI with roles and permissions. Building and maintaining that UI is its own product โ€” buy it.
  • You need a rich audit trail with approvals. Who changed which flag, when, why, and who approved it. Regulated industries and larger orgs need this for compliance, not vanity. An updated_at column doesn't cut it.
  • Segmentation gets genuinely complex. Targeting by plan tier and country and signup date and twelve other attributes, with reusable segments โ€” that's a rules engine, and writing your own rules engine is exactly the over-engineering this article argues against, just in the other direction.
  • You need true real-time propagation. Sub-second streaming updates instead of a polling interval. If a flag flip has to reach every client in under a second, a vendor's streaming SDK is built for it and your 30-second poll isn't.
  • You're running real experiments. Multivariate testing with built-in statistical significance, conversion tracking, and guardrail metrics. That's an experimentation platform, not a flag system, and it's a lot to build well.

If you need those things, LaunchDarkly is worth every euro. The point of the 100-line version isn't that the SaaS is bad โ€” it's that most early-stage teams have none of these needs yet, and buying a platform to solve problems you don't have is how MVPs accrete cost and complexity before they've found product-market fit. Build the small thing now; buy the big thing when the big thing's problems are actually yours.

This is also why feature flags fit so naturally into a multi-tenant SaaS schema: the per-tenant allow-lists above are just another tenant-scoped concern, evaluated against the same tenant_id you're already threading through everything. And if you're standing up a new product, the flag table slots cleanly into the broader build-a-SaaS-with-Next.js checklist โ€” it's a small, early piece of plumbing that pays for itself the first time you need to ship something dark.

The static-config variant

For completeness: if a flag is a permanent on/off you change maybe twice a year, you don't even need the table. A typed config object works:

// Simplest case only โ€” changing a flag here requires a deploy.
const FLAGS = {
  newCheckout: false,
  legacyExportApi: true,
} as const;
 
export const isEnabled = (key: keyof typeof FLAGS): boolean => FLAGS[key];

It's type-safe, it's zero-infrastructure, and it's honest about its one limitation: flipping a flag means a deploy. That rules out kill switches and gradual rollout, which is most of the value. Use it for the genuinely static toggles and use the database version for everything that needs to move at runtime.

Takeaways

  • Feature flags decouple deploy from release โ€” kill switches, gradual rollout, per-tenant targeting, and trunk-based development all fall out of one runtime switch.
  • For a small team, building beats buying. A SaaS prices on MAU, adds a dependency to your hot path, and ships features you won't use for years.
  • Deterministic percentage rollout is the one non-trivial idea. Hash flagKey + userId to a stable bucket โ€” never Math.random(), or the same user flickers on and off every request.
  • Salt the hash with the flag key so each rollout samples an independent slice instead of always picking on the same unlucky cohort.
  • Cache in memory, refresh on an interval, and own the tradeoff: flips propagate within one refresh window, which is still far faster than a rollback deploy.
  • Fail closed. An unknown flag returns off and never throws; a failed refresh serves the last good snapshot.
  • Buy the SaaS when its problems are actually yours โ€” non-engineers flipping flags, audit trails with approvals, complex segmentation, sub-second propagation, or real experimentation. Most early teams have none of these yet.
Iurii Rogulia

Working on something like this?

MVP Development

Building an MVP and not sure which infrastructure to buy versus build? Knowing when a 100-line solution beats a SaaS subscription is exactly the kind of call I help founders get right early.

More about this service

Relevant client work

View all projects
vatnode โ€” EU VAT Validation API
vatnode โ€” EU VAT Validation API
January 19, 2026
vatnode โ€” EU VAT Validation API

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

HTPBE? โ€” Has This PDF Been Edited?
HTPBE? โ€” Has This PDF Been Edited?
September 25, 2024
HTPBE? โ€” Has This PDF Been Edited?

SaaS platform for PDF authenticity verification with a public REST API.

n8n Community Node โ€” EU VAT Validation in Workflows
n8n Community Node โ€” EU VAT Validation in Workflows
August 6, 2026
n8n Community Node โ€” EU VAT Validation in Workflows

Community node that puts VIES VAT validation and EU rate lookups inside n8n, so an onboarding or invoicing workflow can decide on a real VAT number instead of

What clients say

โ€œ

I had a validated idea and a deadline tied to an accelerator demo day, but no product. Iurii helped me cut the scope down to what actually needed to exist for launch and pushed back hard on the features I thought I needed but didn't. We shipped a working MVP in six weeks with real users on it by demo day. The honest scoping at the start is what saved the timeline โ€” it would have been easy to let me build twice as much and miss the date.

Aino Virtanen ๐Ÿ‡ซ๐Ÿ‡ฎ

Founder

Stack

Next.jsTypeScript

Databases

PostgreSQL

Topics

MVPProductScopeSaaS
โ€œ

I'd built most of our MVP with Cursor and it looked finished โ€” it compiled, the tests were green, the demo worked. It just wouldn't survive real users. Iurii took it over and found what I couldn't see: authentication handled four different ways, tests that only asserted what the code already did, and a dependency list half of which was unused. He didn't rewrite it from scratch โ€” he told me honestly what was salvageable, ripped out the dead code, and got it to something a real team could build on. Two weeks and it went from 'looks done' to actually shippable.

Sebastian Falk ๐Ÿ‡ธ๐Ÿ‡ช

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
โ€œ

Our app had slowed to a crawl and the previous developer had left no documentation. Customers were starting to churn over load times. Iurii profiled it instead of guessing, found a handful of unindexed queries and a runaway N+1 that were doing most of the damage, and had page loads back under a second within the first week. He fixed the actual causes rather than throwing more server at it, and wrote up what he changed so we wouldn't repeat the same mistakes.

Owen Pritchard ๐Ÿ‡ฌ๐Ÿ‡ง

CTO

Databases

PostgreSQL

Topics

Technical DebtPerformanceProcess

Related articles

API Key Management for a Public SaaS API
August 5, 2026ยท 13 min
API Key Management for a Public SaaS API

API key management for a public SaaS: hashing keys at rest, prefix + last-4 display, fail-closed validation, and revocation โ€” plus the scoping, rotation, and

Stack

TypeScriptNode.jsHono

Libraries

Drizzle ORM

Databases

PostgreSQL

Topics

SaaSAPIAuthSecurity
Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026ยท 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis

Topics

E-commercePaymentsArchitectureSaaS
Subscription Billing: The Edge Cases Stripe Docs Skip
July 22, 2026ยท 17 min
Subscription Billing: The Edge Cases Stripe Docs Skip

Subscription billing edge cases Stripe glosses over: proration, dunning, the state machine, cancellation timing, refunds, VAT, and webhook ordering.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQL

Services

Stripe

Topics

SaaSPaymentsE-commerceBilling
Idempotency Keys: Building Retries That Don't Double-Charge
July 1, 2026ยท 8 min
Idempotency Keys: Building Retries That Don't Double-Charge

Idempotency key API design: make POST retries that don't double-charge โ€” Postgres key table, ON CONFLICT claim, response replay, TTL, edge cases.

Stack

TypeScriptNode.js

Databases

PostgreSQL

Topics

APISaaSBackend
Multi-Tenant SaaS Database Design: Shared Schema vs Schema-per-Tenant
June 19, 2026ยท 18 min
Multi-Tenant SaaS Database Design: Shared Schema vs Schema-per-Tenant

Multi-tenant SaaS database design: shared schema with RLS, schema-per-tenant, and database-per-tenant โ€” trade-offs, real pitfalls, and when to use each.

Stack

TypeScriptNode.js

Libraries

Drizzle ORM

Databases

PostgreSQL

Topics

SaaSArchitectureSecurity