Distributed Stores
s200's rateLimit and createSession batteries ship with in-process defaults: a per-key timestamp deque, a map with lazy expiry. Those defaults are per-process — run three instances behind a load balancer and each enforces its own limit, remembers its own sessions, and a client that hops instances starts from zero every hop. The fix is not more code in the framework; it is the seam both batteries already have: inject a store the whole fleet shares.
This guide wires that seam to Redis with the official recipes in recipes/redis-stores.ts — repository-level TypeScript, deliberately not a package export (s200 is zero-dependency by contract). Vendor the one file into your project, bring your own Redis client, and inject.
import { Redis } from 'ioredis';
import { createRedisRateLimitStore, createRedisSessionStore } from './redis-stores'; // vendored
const redis = new Redis(process.env.REDIS_URL!);
const rl = createRedisRateLimitStore(redis);
const sessions = createRedisSessionStore(redis, 'myapp:sess:');The two seams
Both contracts are small enough to read in one breath:
// rateLimit: one method, and it must be atomic per key
type RateLimitStore = {
hit(key: string, now: number, limit: number, windowMs: number):
RateLimitHit | Promise<RateLimitHit>; // { count, retryAt }
};
// createSession: three methods; set receives the cookie's TTL
type SessionStore = {
get(id: string): SessionData | undefined | Promise<SessionData | undefined>;
set(id: string, data: SessionData, ttlSeconds: number): void | Promise<void>;
delete(id: string): void | Promise<void>;
};Anything that fulfills these over shared state works — Redis here, a SQL table, a KV namespace. The store decides where state lives; the battery keeps its behavior (429 + Retry-After in place, cookie handshake, expiry-on-read) untouched.
RedisLike: bring your own client
The recipes don't import a Redis library; they declare the four commands they need as a structural type:
type RedisLike = {
eval(script: string, numKeys: number, ...keysAndArgs: Array<string | number>):
unknown[] | Promise<unknown[]>;
get(key: string): string | null | Promise<string | null>;
setex(key: string, seconds: number, value: string): unknown | Promise<unknown>;
del(key: string): unknown | Promise<unknown>;
};ioredis satisfies it as-is — pass the client directly.
node-redis (v4+) differs on two spellings: eval takes an options object ({ keys, arguments }) rather than the positional form, and SETEX is camel-cased. Four lines reconcile it:
import { createClient } from 'redis';
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
const redis = {
eval: (script: string, numKeys: number, ...rest: Array<string | number>) =>
client.eval(script, {
keys: rest.slice(0, numKeys).map(String),
arguments: rest.slice(numKeys).map(String),
}) as Promise<unknown[]>,
get: (key: string) => client.get(key),
setex: (key: string, seconds: number, value: string) =>
client.setEx(key, seconds, value),
del: (key: string) => client.del(key),
};Rate limiting: one EVAL, atomic by construction
import { createApp, get, use } from 's200';
import { rateLimit } from 's200/rate-limit';
import { createRedisRateLimitStore } from './redis-stores'; // vendored
const store = createRedisRateLimitStore(redis); // keys under 's200:rl:'
const app = createApp();
use(app, rateLimit({ windowMs: 60_000, limit: 100, store }));
get(app, '/', (ctx) => new Response('ok'));The store's entire accounting is one Lua script, executed as one EVAL:
local count = redis.call("INCR", KEYS[1])
local ttl = redis.call("PTTL", KEYS[1])
if ttl < 0 then
redis.call("PEXPIRE", KEYS[1], ARGV[1])
ttl = tonumber(ARGV[1])
end
return { count, ttl }Why one script and not two commands. The naive port — INCR key, then EXPIRE key windowMs if the reply was 1 — has the classic race: two instances increment concurrently, both can see intermediate values (or miss the 1 entirely, leaving the TTL unarmed), and a process dying between the two commands leaves an immortal counter that blocks a key forever. Redis executes each script serially, so the increment, the TTL check, and the (conditional) PEXPIRE land as a single unit — every instance of every process observes the same count, and the window is armed exactly once, by whoever's increment created the key. The ttl < 0 branch also self-heals: a stray TTL-less key written by some other tool gets armed rather than counting forever. rateLimit turns the reported PTTL into retryAt, which the battery renders as Retry-After — blocked callers are told the true remainder of the window, not a guess.
Window semantics — know what you deploy. The in-process default is a true sliding window: each hit expires exactly windowMs after it landed, so the limit is burst-proof at window edges. The Redis recipe is a fixed window anchored at the first hit: the counter expires wholesale. Two consequences:
- at a window edge, a client can place up to
limitrequests just before expiry and anotherlimitjust after — the fixed-window burst; - the limit holds exactly across the fleet — which is the property multi-instance deployment actually needs and the in-memory default cannot offer.
If edge bursting matters at your limits, shrink windowMs proportionally (a 30s window at half the limit approximates a 60s sliding budget), or graduate the script to a sorted-set window (ZREMRANGEBYSCORE + ZADD + ZCARD + PEXPIRE, still one EVAL) — the store contract takes any implementation that reports an honest { count, retryAt }.
Keys and prefixes. The key is whatever the battery's key option produces (first x-forwarded-for hop by default — meaningful only behind a proxy that overwrites it), prefixed s200:rl:. Several apps sharing one Redis should pass distinct prefixes: createRedisRateLimitStore(redis, 'api-a:rl:').
Sessions: records that die with their cookies
import { createSession } from 's200/session';
import { createRedisSessionStore } from './redis-stores'; // vendored
const { middleware } = createSession({
secret: process.env.SESSION_SECRET!,
store: createRedisSessionStore(redis), // keys under 's200:sess:'
});The mapping is three commands:
set(id, data, ttlSeconds)→SETEX s200:sess:<id> <ttlSeconds> <json>— the TTL the battery passes is the cookie'smaxAge, so the record in Redis and the cookie in the browser die together. No id outlives its credential; no sweep job required.get(id)→GET s200:sess:<id>,JSON.parse— Redis has already evicted expired records, and a record that fails to parse counts as no session: the middleware mints a fresh one instead of failing the request.delete(id)→DEL s200:sess:<id>— whatsession.destroy()triggers.
Sliding expiry. session.touch() renews on the unwind: the middleware re-runs set with the same maxAge, and the fresh SETEX slides the record's (and cookie's) death forward — idle sessions expire, active ones live as long as they stay active. Mutations (set/delete/clear) persist the same way; a request that only reads leaves no write and no Set-Cookie, so Redis is not hammered by traffic that never touches its session.
Instance freedom. Because the record — not the instance — is the source of truth, any instance can serve any request: sessions survive deploys, restarts, and load-balancer whims, and the signed-cookie handshake (s200.sid + s200.sid.sig) travels the client where the data cannot.
KV variants (Upstash and friends)
The recipe's shape ports to any Redis-compatible edge:
- Upstash Redis speaks EVAL over its REST API, and
@upstash/redisderivesnumkeysfrom separate key/arg arrays — adapt the oneevalline:(script, keys, args) => upstash.eval(script, keys, args). ItsScripthelper caches by SHA for fewer bytes over the wire, and the official@upstash/ratelimitpackage ships fixed-window, sliding-window, and token-bucket Lua if you want its client instead of the battery — the seams are interchangeable. - KV without Lua (plain KV namespaces): you cannot have one atomic INCR-with-TTL. Either accept a tiny race (INCR, then set expiry only when the reply was
1— a failed write between the two leaves an immortal counter a periodic cleanup must reap), or approximate with per-window keys (rl:<key>:<floor(now/windowMs)>) whose TTL is the atomicity — each window's counter dies on schedule no matter what. For sessions, KVputwithexpirationTtlmaps tosetone-to-one;expirationTtlhas a 60-second floor, so sub-minute cookie TTLs need the record's expiry checked on read.
The general rule the recipes encode: keep the atomic decision in as few round trips as the backend allows, let TTLs do the cleanup, and align every expiry with the credential that fronts it.