Skip to content

Running on multiple servers

By default Warlock keeps state in the process: memory cache, local disk, in-process socket rooms, in-process counters. That is right for one server. With two or more, each piece needs a shared backend. In production Warlock warns once at boot for the ones it can detect.

ConcernSingle-server defaultMulti-server setupSilence the warning
Cache, repository cache, locksMemory driverredis or pg cache driver — see Cachecache.silenceSingleServerWarning
FilesLocal diskS3 or R2 — see Storage and Upload to S3storage.silenceSingleServerWarning
Rate limits (global and middleware.rateLimit)In-processhttp.rateLimit.store: "cache" over a shared cache drivern/a
SocketsIn-process roomssocket.adapter + sticky sessionssocket.silenceSingleServerWarning
Scheduled jobsRuns on every serveronOneServer — see Overlap preventionn/a
Idempotency keysCache-backedShared cache (above)n/a

Counts live in the process by default, so with N servers every client gets N × max. Set store: "cache" to count in @warlock.js/cache instead: every server using the same shared cache driver (redis, pg) counts together, and the per-route middleware.rateLimit() follows the same setting.

export default {
rateLimit: {
max: 100,
duration: 60_000,
store: "cache",
},
};

Shared windows are fixed and aligned to the clock (a 60-second window resets on the minute). Each route with its own rateLimit counts separately. A per-route limiter can choose for itself with middleware.rateLimit({ ..., store: "cache" | "memory" }); give it a name when limiters are created conditionally, so every server keys it the same way.

When the cache is down, both limiters fail open: the request goes through, and the error is logged at most once a minute. For the global limiter that means skipOnError defaults to true with this store; set skipOnError: false to reject requests instead (they get a 500 while the cache is down).

Two cache drivers are refused for store: "cache", with UnsupportedRateLimitDriverError (requests then pass, and the refusal is logged the same way):

  • null stores nothing, so the limit would never trip.
  • file never removes expired windows, so every past window would stay on disk, and its increment is only serialized within one process, so workers sharing the directory would lose counts.

Use redis (any version from 6) or pg across servers, or a memory driver for a single process.

http.rateLimit also passes @fastify/rate-limit options through. Its own redis option still works but needs an ioredis client; the redis driver of @warlock.js/cache uses node-redis, so share that connection through store: "cache". A @fastify/rate-limit store class can be passed as store too. enabled: false turns the global limiter off.

End-to-end: one Redis window across two servers

Section titled “End-to-end: one Redis window across two servers”

Install the app’s Redis driver dependency with npm install redis. Use the same Redis database and application prefix on every instance; different apps should have different prefixes. Declare the driver in src/config/cache.ts:

src/config/cache.ts
import { RedisCacheDriver, type CacheConfigurations } from "@warlock.js/cache";
import { env } from "@warlock.js/core";
export default {
default: "redis",
drivers: { redis: RedisCacheDriver },
options: {
redis: {
url: env("REDIS_URL"),
globalPrefix: "shop.production",
ttl: 0,
},
},
} satisfies CacheConfigurations;

Keep REDIS_URL identical on both servers and enable the shared store:

src/config/http.ts
import type { HttpConfigurations } from "@warlock.js/core";
export default {
rateLimit: { max: 2, duration: 60_000, store: "cache", skipOnError: false },
} satisfies HttpConfigurations;

Add an app route:

src/app/catalog/routes.ts
import { router } from "@warlock.js/core";
router.get("/catalog/ping", ({ response }) => response.success({ ok: true }));

Run the same app on ports 3000 and 3001 with the same Redis configuration. From one client, within one clock-aligned window, request server A once, server B once, then server A again. The first two return 200 and the third returns 429 with Retry-After and rate-limit headers. Counters reset on the next minute boundary; repeat away from that boundary for a deterministic manual check. Behind a proxy, configure http.trustProxy for your actual proxy addresses so both instances see the same client IP. Avoid unrelated requests from that IP during the check because the global bucket is shared across routes.

Atomic increments pass TTL on every call, even though this driver has no default TTL. An existing counter’s deadline stays fixed; recreating an expired counter restores its expiry. This example explicitly chooses skipOnError: false: an unavailable cache yields an error instead of allowing uncounted traffic. Omit it for the documented fail-open default. Memory and file drivers do not provide cross-server counting. Cache integration CI tests exercise two independent Redis clients and real expiry recreation.

import { createAdapter } from "@socket.io/redis-adapter";
export default {
adapter: () => createAdapter(pubClient, subClient),
};

Enable sticky sessions on the load balancer so long-polling requests from one client reach the same server.

middleware.idempotency() reserves the key before the handler runs; a concurrent duplicate gets 409 + Retry-After. That only holds across servers when the cache is shared.

The repository cache is cleared after the model event and again after the transaction commits. With a memory cache, other servers keep their own copies — use redis or pg.

Cache-backed rate limiting passes its window TTL on every atomic increment. A key recreated after the create-only seed expires still gets an expiry, without extending an existing window. Configure a shared Redis or Postgres driver; no driver default TTL is required for these counters.