Get started
Back to blog

Feature flags in Express: middleware patterns that actually work

Express is old enough that every feature flag pattern you could want has been built, broken, and rebuilt. This post is the shortlist of patterns that don’t suck.

Install

npm install @flagify/node express

Basic setup: singleton client

Create one Flagify client at startup. Reuse it everywhere. Do not create a new client per request — that defeats the local cache and will crash your app.

// lib/flagify.ts
import { Flagify } from '@flagify/node'

export const flagify = new Flagify({
  projectKey: 'my-project',
  publicKey: process.env.FLAGIFY_PUBLIC_KEY!,
  options: { realtime: true },
})

In your entrypoint, wait for the client to be ready before taking traffic:

// app.ts
import express from 'express'
import { flagify } from './lib/flagify'

const app = express()

await flagify.ready()

app.listen(3000, () => {
  console.log('Server ready, flags synced')
})

Pattern 1: per-route flag gate

The simplest case. You want to gate an entire route behind a flag.

import { flagify } from './lib/flagify'

function requireFlag(key: string) {
  return (req, res, next) => {
    if (!flagify.isEnabled(key)) {
      return res.status(404).json({ error: 'Not found' })
    }
    next()
  }
}

app.get('/beta/dashboard', requireFlag('new-beta-dashboard'), handleBetaDashboard)

Return 404 (not 403) if the feature is off. Users should not know the route exists.

Pattern 2: per-user evaluation

When the flag has targeting rules, you need the user context. This is where people shoot themselves in the foot.

Wrong — do not do this:

// DON'T: creates a new client per request
app.use((req, res, next) => {
  const client = new Flagify({
    options: { user: req.user },
  })
  req.flagify = client
  next()
})

Right — use one singleton client, evaluate per request with the user:

app.use(async (req, res, next) => {
  if (!req.user) return next()

  const evaluate = (key: string, fallback: boolean = false) =>
    flagify.evaluate(key, {
      id: req.user.id,
      role: req.user.role,
      plan: req.user.plan,
    }).then(r => r.value ?? fallback)

  req.isEnabled = evaluate
  next()
})

app.get('/dashboard', async (req, res) => {
  const canExport = await req.isEnabled('csv-export')
  res.render('dashboard', { canExport })
})

flagify.evaluate() calls the API and applies targeting rules server-side. The singleton client handles connection pooling.

Pattern 3: global middleware (maintenance mode)

Some flags affect every request. Kill switches for maintenance, emergency blocks, rate limit overrides.

app.use((req, res, next) => {
  if (flagify.isEnabled('maintenance-mode')) {
    return res.status(503).render('maintenance')
  }
  next()
})

Put this early in the middleware chain, before auth and parsing. You want to short-circuit as fast as possible when maintenance mode is on.

Pattern 4: conditional middleware

Enabling or disabling a whole middleware based on a flag. For example, a new rate limiter you want to test.

import rateLimitV1 from './middleware/rate-limit-v1'
import rateLimitV2 from './middleware/rate-limit-v2'

app.use((req, res, next) => {
  const useV2 = flagify.isEnabled('rate-limit-v2')
  const middleware = useV2 ? rateLimitV2 : rateLimitV1
  middleware(req, res, next)
})

The flag is checked per request. If you flip it, the next request uses the new middleware. No restart.

Pattern 5: feature-gated response payloads

Same route, different shape based on flag state. Common when migrating API versions.

app.get('/api/user', async (req, res) => {
  const user = await db.users.get(req.user.id)
  const newFormat = await req.isEnabled('api-v2-user-shape', false)

  if (newFormat) {
    return res.json({
      user: { id: user.id, profile: user.profile, settings: user.settings },
    })
  }

  return res.json({
    id: user.id,
    profile: user.profile,
    settings: user.settings,
  })
})

Things to avoid

Awaiting flagify.isEnabled() every request. isEnabled() is synchronous and reads from the local cache. Use evaluate() (which is async and hits the API) only when you need user-level evaluation.

Calling evaluate() in a hot path without caching. evaluate() makes an HTTP request. For a request-scoped evaluation you probably want to cache the result in req so downstream handlers don’t hit the API again.

Not calling flagify.destroy() on shutdown. Graceful shutdown should close the SSE connection. Express apps that don’t do this leak connections on redeploy.

process.on('SIGTERM', async () => {
  await flagify.destroy()
  process.exit(0)
})

Using client-side keys on the server. pk_* is your public key. On the server you want it but it does not expose secrets. sk_* is the secret key — server-only, never in client code.

For Fastify / Koa / Hono

The same patterns apply. The middleware syntax differs but the client usage is identical. @flagify/node is framework-agnostic.


Flagify has full Node.js SDK documentation with more patterns for server-side evaluation. Or read the React SDK docs if you’re building the frontend.

Start for free — no credit card required.