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.