Rate limiting

Dynamic rate limiting with feature flags

You change rate limits at runtime without redeploying, driven by a feature flag. Keep a single @arcjet/node v1 client, read mode and numeric ceilings from LaunchDarkly client.variation(), and attach them with withRule() before protect(). Use Node 22 LTS. Express 5 is current; Express 4 still works. Pre-create live, dry-run, and clamped variations so an emergency surge is a dashboard change, not a release.

8 min read
In short: You change rate limits at runtime without redeploying, driven by a feature flag. Keep a single @arcjet/node v1 client, read mode and numeric ceilings from LaunchDarkly client.variation(), and attach them with withRule() before protect(). Use Node 22 LTS. Express 5 is current; Express 4 still works. Pre-create live, dry-run, and clamped variations so an emergency surge is a dashboard change, not a release.

What is dynamic rate limiting with feature flags?

You change rate limits at runtime without redeploying, driven by a feature flag. The SDK still runs in your process. The flag service supplies the numbers and modes. You rebuild the rule on each request (or on a short cache) with withRule(), then call protect().

That is useful when a paying customer needs a higher ceiling, when an endpoint should allow known bots, or when you normally run open and need to clamp traffic during an incident. You can also load limits from a database or a session. Feature flags are the option that a non-deploying operator can flip from a dashboard.

This guide uses @arcjet/node v1 with Express, plus LaunchDarkly's Node server SDK. The same pattern works in any Arcjet JS adapter. For algorithm choice, see the rate limiting guide. For deciding whether a spike is a customer or an attack, see DoS versus legitimate traffic.

Why put rate limits behind a flag?

A hardcoded slidingWindow({ max: 10, interval: 60 }) is fine until the night you need max: 2 on an anonymous route and max: 200 on a billed integration. A deploy is the wrong control loop for that. Flags give you:

  • Instant mode changes between DRY_RUN and LIVE.
  • Numeric ceilings you can clamp without a release.
  • Per-context targeting so one tenant can stay wide while guests tighten.

Keep per-request cost and identity in code. Keep emergency and experiment knobs in flags. Do not put secrets in flag values.

What runtime should you use?

@arcjet/node v1 requires Node.js 22.21.0 or later and is ESM only. Use Node 22 LTS. Express 5 is the current Express release line; Express 4 still runs, but do not pin express@^4 or @types/node@^20 as if they were the present baseline. The following example is Express 5 on Node 22. The app.get and res.status calls are the same shape on Express 4.

LaunchDarkly's Node server SDK still evaluates flags with client.variation(key, context, defaultValue). That method remains the primary evaluation API. Use a context object (kind plus key), not a deprecated user-only type.

How do you protect an Express route with Arcjet?

Create a small ESM app:

Terminal window
npm init -y
npm i express @arcjet/node@1.10.0
npm i --save-dev @types/node typescript

Set "type": "module" in package.json and start with Node's env-file flag:

{
"private": true,
"type": "module",
"scripts": {
"start": "node --env-file .env.local ./index.js"
}
}

.env.local holds the site key and, later, the LaunchDarkly SDK key:

Terminal window
ARCJET_ENV=development
ARCJET_KEY=
LAUNCHDARKLY_SDK_KEY=

Create a reusable Arcjet client with an empty rule list. You attach live rules after you read flags:

lib/arcjet.js
import arcjet from "@arcjet/node";
export const aj = arcjet({
key: process.env.ARCJET_KEY,
rules: [],
});

In the route, call protect(req) and branch on the decision:

index.js
import express from "express";
import { aj } from "./lib/arcjet.js";
import { shield, slidingWindow } from "@arcjet/node";
const app = express();
app.get("/", async (req, res) => {
const decision = await aj
.withRule(shield({ mode: "LIVE" }))
.withRule(slidingWindow({ mode: "LIVE", max: 10, interval: 60 }))
.protect(req);
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
return res.status(429).send("Too many requests");
}
return res.status(403).send("Forbidden");
}
res.send("Hello World!");
});
app.listen(3000, () => {
console.log("Server started at http://localhost:3000");
});

slidingWindow takes interval as a number of seconds. Eleven requests inside 60 seconds return 429. shield denies common application-layer attack patterns with 403. This hardcoded version is the control you will replace with flag values.

How do you define the LaunchDarkly flags?

Create four flags. Use permanent (non-temporary) custom flags so an incident toggle is not cleaned up as an experiment.

The following table is the flag set this example reads:

KeyTypeWhen targeting is onWhen targeting is off
shieldModeStringLIVEDRY_RUN
slidingWindowModeStringLIVEDRY_RUN
slidingWindowMaxNumber100 (regular)2 (clamped)
slidingWindowIntervalNumber60 (regular)10 (clamped)

Turn each flag on after you create it. Copy the server SDK key from the LaunchDarkly environment (the test environment is fine for local work) into LAUNCHDARKLY_SDK_KEY.

You can store the same object in one JSON flag if you prefer a single evaluation. Four typed flags keep the dashboard obvious during an incident.

How do you read flags with client.variation()?

Install the server SDK and evaluate once per request (or cache for a few seconds). variation() is async in the current Node server SDK and needs a context plus a default.

Terminal window
npm i @launchdarkly/node-server-sdk
lib/launchdarkly.js
import * as ld from "@launchdarkly/node-server-sdk";
const client = ld.init(process.env.LAUNCHDARKLY_SDK_KEY);
// The policy that applies when the flag service cannot answer.
const DEFAULTS = {
shieldMode: "LIVE",
slidingWindowMode: "LIVE",
slidingWindowMax: 100,
slidingWindowInterval: 60,
};
const MODES = new Set(["LIVE", "DRY_RUN"]);
const asMode = (value, fallback) => (MODES.has(value) ? value : fallback);
// Wait for the client once, at module scope, not on every request. The
// timeout is in seconds and the promise rejects when it expires, so swallow
// the rejection here rather than letting it reach a handler.
const ready = client
.waitForInitialization({ timeout: 5 })
.then(() => true)
.catch(() => false);
export async function getArcjetConfig() {
if (!(await ready)) {
return DEFAULTS;
}
const context = { kind: "user", key: "guest" };
const shieldMode = await client.variation("shieldMode", context, "LIVE");
const slidingWindowMode = await client.variation(
"slidingWindowMode",
context,
"LIVE",
);
const slidingWindowMax = await client.variation(
"slidingWindowMax",
context,
100,
);
const slidingWindowInterval = await client.variation(
"slidingWindowInterval",
context,
60,
);
return {
// A flag value is external input. `shield({ mode })` throws on anything
// that is not exactly "LIVE" or "DRY_RUN", so a stray "live" typed into
// the dashboard mid-incident would 500 every request.
shieldMode: asMode(shieldMode, DEFAULTS.shieldMode),
slidingWindowMode: asMode(slidingWindowMode, DEFAULTS.slidingWindowMode),
slidingWindowMax: Number.isInteger(slidingWindowMax)
? slidingWindowMax
: DEFAULTS.slidingWindowMax,
slidingWindowInterval: Number.isInteger(slidingWindowInterval)
? slidingWindowInterval
: DEFAULTS.slidingWindowInterval,
};
}

The third argument to variation() is the fallback when the client is not ready or the key is missing. Fail toward the safer default for the route: LIVE and a low max on login, DRY_RUN and a high max on a public marketing page.

Two failure modes deserve those extra lines. waitForInitialization takes an options object whose timeout is in seconds, and it rejects when the timeout expires rather than resolving with defaults, so an unguarded await turns a flag-service outage into a failed request. Resolve it once at module scope: awaiting it per request pays the timeout on every call during a slow start and gives you nothing the first await did not. And the whole point of this design is that someone edits these values from a dashboard while under pressure, which is exactly when a typo happens, so coerce what comes back before you hand it to a rule constructor.

Wire the values into withRule():

lib/arcjet.js
import _arcjet, { shield, slidingWindow } from "@arcjet/node";
import { getArcjetConfig } from "./launchdarkly.js";
const base = _arcjet({
key: process.env.ARCJET_KEY,
rules: [],
});
export default async function arcjet() {
const config = await getArcjetConfig();
return base.withRule(shield({ mode: config.shieldMode })).withRule(
slidingWindow({
mode: config.slidingWindowMode,
max: config.slidingWindowMax,
interval: config.slidingWindowInterval,
}),
);
}

The route stays the same: const aj = await arcjet(); const decision = await aj.protect(req);. You still construct base once. You add rules per request from the flag payload you just read.

Pass a real user or tenant key in the LaunchDarkly context when you want targeted ceilings. Then one customer can keep the regular variation while guests receive the clamped values.

What do you do in an emergency traffic surge?

Treat the flag dashboard as the incident control, not a deploy pipeline. Work through this runbook:

  1. Confirm what is growing. One authenticated customer, one API key, or one path is often legitimate load. Many unrelated IPs on login or checkout is closer to an attack. See DoS versus legitimate traffic before you clamp everyone.
  2. If Shield or the limiter is in DRY_RUN, switch shieldMode and slidingWindowMode to LIVE and save. Wait for the SDK stream to land (usually a few seconds).
  3. If anonymous or expensive routes are still too hot, serve the clamped number variations: drop slidingWindowMax (for example 100 to 2) and, if you need a faster reset, drop slidingWindowInterval (for example 60 to 10).
  4. Target the clamp. Apply the tight numbers to guests, a suspect segment, or a single route's context key. Leave known high-volume customers on the regular variation so you do not create an SLA incident.
  5. Watch 429 rate, error budgets, and support load. If paying traffic is the surge, raise that segment's max instead of fighting it with a global clamp.
  6. After the event, restore regular variations, write down what you changed, and decide whether the new numbers belong in code as the default.

Do not invent new flag keys in the middle of an incident. Pre-create LIVE / DRY_RUN and regular / clamped variations so the only action is targeting and save.

How do you run this in production?

Initialize one LaunchDarkly client for the process. Do not call ld.init per request. The client holds the flag store and serves variation() locally after the first handshake.

Start rules in DRY_RUN, watch the Arcjet dashboard, then flip the mode flag to LIVE. Log the flag values you applied, the decision conclusion, and the characteristic. When LaunchDarkly is down, your defaults run. Pick those defaults per route: fail open on a public catalog, fail closed on password reset.

Dynamic limits do not replace identity. Key the sliding window on userId or an API key when you have one, and keep IP as the guest fallback. Flags change the numbers. Characteristics decide who shares the budget.

Frequently asked questions

How do you change a rate limit without redeploying?

Store mode, max, and interval in feature flags. On each request, read them with LaunchDarkly client.variation(), attach shield and slidingWindow through withRule(), and call protect(). Saving a new variation in the flag dashboard updates running processes without a deploy.

Does LaunchDarkly `client.variation()` still exist?

Yes. The Node server SDK still evaluates flags with client.variation(key, context, defaultValue). Pass a context with kind and key, and keep one initialized client per process.

What should you do during a traffic surge?

Confirm whether the spike is one customer or many unrelated clients. Flip modes to LIVE if they were dry-run, serve pre-created clamped max and interval values, target guests rather than known high-volume customers, watch 429 rates, then restore regular variations and record what you changed.

Should you pin Express 4 and Node 20?

No. @arcjet/node v1 requires Node.js 22.21.0 or later. Use Node 22 LTS. Express 5 is the current Express release. Express 4 still runs, but new examples should not treat Express 4 and Node 20 as the current baseline.

Application security in your code

Protect your application with Arcjet

Get rate limits, bot detection, and attack blocking in your request handlers.