Two-layer rate limiting with Traefik and express-rate-limit backed by Redis
Without rate limiting, one script can exhaust the AI provider key within minutes. Two independent layers address this: Traefik limits by IP, while express-rate-limit with Redis backing limits by authenticated user. Why both layers matter and how to handle the Redis adapter’s TypeScript mismatch.

Contents
- Why one API key can be exhausted in minutes
- Why two layers
- Layer 1 with Traefik RateLimit middleware
- Layer 2 with express-rate-limit and Redis backing
- The TypeScript mismatch in the Redis adapter
- Middleware ordering
- What standardHeaders provide
- Preparing for Phase 4
- All articles in the series
Why one API key can be exhausted in minutes
Local Insight has two expensive endpoints: POST /ingest and POST /query. Each call triggers at least one embedding API call. With OpenAI, that costs money. With Ollama, it costs GPU time.
Before rate limiting, the situation was straightforward. Anyone who knew the extension API URL and had a valid token could send unlimited requests. A script with a simple for loop could have exceeded the ingest limit or placed the query endpoint under continuous load.
For a system moving to quota-based billing in Phase 4, this is an immediate problem. Quota limits in code are worthless if a user can call the underlying API without control.
Why two layers
One rate limiter is not enough because the two layers stop different attackers.
Layer 1 at Traefik level works by IP address. It stops bots and simple attacks before a request even reaches Node.js. No JavaScript code executes and no database call happens. This is inexpensive and effective against high request volumes.
Layer 2 at Express level works by authenticated userId. Two users behind the same NAT gateway share an IP address. Layer 1 would limit them together, producing false positives. Layer 2 knows the JWT contents and can limit precisely by account.
The opposite case is one user with several IP addresses, through VPN changes or mobile and Wi-Fi connections. That user could bypass Layer 1. Layer 2 still limits them because the JWT always contains the same userId.
The layers complement each other. Together, they close the gaps each leaves on its own.
Layer 1 with Traefik RateLimit middleware
Traefik provides RateLimit middleware configured through Docker labels on the service:
# docker-compose.override.yml
backend:
labels:
- 'traefik.http.middlewares.backend-ratelimit.ratelimit.average=60'
- 'traefik.http.middlewares.backend-ratelimit.ratelimit.burst=20'
- 'traefik.http.routers.backend.middlewares=backend-ratelimit'average=60 means 60 requests per minute. burst=20 allows short peaks of up to 20 requests before the token bucket algorithm applies. Traefik counts by client IP address.
This is Layer 1. It consumes no Node.js runtime.
Layer 2 with express-rate-limit and Redis backing
For per-user limits, I use express-rate-limit with rate-limit-redis as the store.
The in-memory store in express-rate-limit is the wrong approach for this stack. If the backend scales to several instances, each has its own counter. Users could bypass limits by sending parallel requests to different instances. Redis makes the counters global.
import rateLimit from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import { createClient } from 'redis';
const redisClient = createClient({
url: process.env.REDIS_URL ?? 'redis://redis:6379',
});
await redisClient.connect();
function makeRateLimiter(max: number, windowMs: number) {
return rateLimit({
windowMs,
max,
// Key by authenticated userId, fall back to IP before auth middleware
keyGenerator: (req) => (req as AuthenticatedRequest).auth?.userId ?? req.ip ?? 'unknown',
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
// TypeScript requires explicit sendCommand signature
sendCommand: (command: string, ...args: string[]) =>
redisClient.sendCommand([command, ...args]) as Promise<string | number>,
}),
});
}
const ingestLimiter = makeRateLimiter(parseInt(process.env.RATE_LIMIT_INGEST ?? '30', 10), 60_000);
const queryLimiter = makeRateLimiter(parseInt(process.env.RATE_LIMIT_QUERY ?? '20', 10), 60_000);The limits are configurable through environment variables. This lets them be adjusted without deployment once real usage data is available.
The TypeScript mismatch in the Redis adapter
rate-limit-redis expects a sendCommand callback with a specific signature. The Node.js redis package provides sendCommand with a different type definition.
The first, obvious pattern does not work:
// Error: type spread mismatch between redis and rate-limit-redis
sendCommand: (...args: string[]) => redisClient.sendCommand(args),The error occurs because redisClient.sendCommand returns Promise<unknown>, while rate-limit-redis expects Promise<string | number>. The spread parameter also conflicts with the type definitions.
The working signature:
// Explicit first arg avoids spread type mismatch, cast resolves Promise<unknown>
sendCommand: (command: string, ...args: string[]) =>
redisClient.sendCommand([command, ...args]) as Promise<string | number>,command is separated into its own string argument and then combined again with ...args. Casting to Promise<string | number> is safe because Redis commands used in rate limiting, such as INCRBY, GET and TTL, always return numbers or strings.
Middleware ordering
The rate limiters are registered before the route handlers:
// Apply rate limiters before route handlers
app.post('/ingest', ingestLimiter, authMiddleware, ingestHandler);
app.post('/query', queryLimiter, authMiddleware, queryHandler);This is deliberate. The rate limiter runs before authMiddleware because authMiddleware can make a JWT-related call. Without advance limiting, the authentication check itself would be exposed to abuse.
Inside the rate limiter, keyGenerator accesses req.auth?.userId. If auth is not yet set because authMiddleware has not run, it falls back to req.ip. This is the correct fallback: before authentication, IP is the best available signal.
What standardHeaders provide
standardHeaders: true means Express sends the standard RateLimit headers with a 429 response:
RateLimit-Limit: 30
RateLimit-Remaining: 0
RateLimit-Reset: 1717430460This lets clients and the extension implement retry logic correctly: wait until RateLimit-Reset, then send again. Without these headers, clients have to back off blindly.
Preparing for Phase 4
The per-user counters in Redis provide the basis for quota enforcement in Phase 4. If a free-tier user has 50 ingest calls per month, I need a persistent counter per account and billing period.
The rate-limiting layer demonstrates the pattern: a Redis key based on userId, increment the counter and check it against the limit. Phase 4 does the same with different windows, a month rather than a minute, and different consequences, an upgrade prompt rather than a 429 response.
The infrastructure is ready. The counter logic only needs to be adapted to a different period.

The diagram shows the request path through both limiting layers. Traefik checks the IP token bucket before Node.js starts. Express checks the Redis counter keyed by userId. Either layer can independently return 429. The original diagram retains its German labels.
All articles in the series
- Vision and system overview: Chrome Extension, RAG architecture and project background: Read article
- Building a RAG system: Qdrant, embeddings and cosine similarity in TypeScript: Read article
- AI provider abstraction: Ollama vs. OpenAI, interface design and no vendor lock-in: Read article
- Chrome Extension MV3: Three isolated runtime contexts, message passing and Strategy Pattern: Read article
- Docker Compose strategy: The override pattern, from local development to Azure: Read article
- Ollama locally vs. Docker: The decision and its consequences: Read article
- Ollama auto-pull entrypoint: Automated model setup at container startup: Read article
- tsconfig and Vite:
Node16vs.bundler, and why Vite has its own rules: Read article - Load complete Instagram captions with MutationObserver: Read article
- Chrome Extension foundation with health indicator and retry queue: Read article
- Phase 2 features: Shadow DOM overlay, Tailwind v4 and duplicate detection: Read article
- Race condition in platform detection: How a UI event breaks Instagram detection: Read article
- Extracting PostId in two Instagram layouts: querySelector vs. ancestor traversal: Read article
- Capture a complete Instagram carousel with MutationObserver: Lazy loading, observer-before-click and timeout fallback: Read article
- Notes and tags when saving screenshots: Read article
- Instagram keyboard shortcuts block Chrome Extension input: Read article
- Lowercase normalization and duplicate detection in tag input: Read article
- Zitadel Login V2 in Docker Compose with three hidden errors: Read article
- PKCE OAuth in a Chrome MV3 extension: Read article
- React frontend with react-oidc-context and Zitadel: Read article
- Vite build-time environment variables in Docker: Read article
- Event-driven ingestion with BullMQ and Redis: Read article
- MinIO instead of Azurite: S3-compatible object storage locally and on Hetzner: Read article
- access_token, id_token and the Userinfo endpoint: Which belongs where: Read article
- Qdrant multi-tenancy with one collection per user: Read article
- When backend and frontend know different types: Read article
- Removing Zitadel bootstrap: Host header bug and manual setup: Read article
- Backend code review with six problems fixed before launch: Read article
- Traefik instead of NGINX: Reverse proxy for a growing Docker Compose stack: Read article
- Two-layer rate limiting: Traefik and express-rate-limit with Redis: (this article)
- Implementing GDPR Article 17 correctly: Promise.allSettled and export batching: Read article
- Embedding model lock-in: Why mxbai-embed-large is a permanent production decision: Read article
- Docker volumes in production: Named volumes, bind mounts and the Hetzner volume approach: Read article
- Two vulnerabilities before launch: Redis without authentication and an exposed Qdrant admin port: Read article
- Traefik as the single entry point in the Docker Compose stack: Read article
- Connecting Zitadel behind Traefik with issuer, JWKS and Login V2: Read article
- Fixing the frontend when the nginx healthcheck fails on localhost: Read article
- Observability for my Docker Compose stack with Bull Board and Dozzle: Read article
- Open the Qdrant dashboard securely with local Traefik and an SSH tunnel: Read article
- Diagnosis of chunking failures despite tokenization: Read article
- Why I set the chunk size to 1500 tokens: Read article
- Splitting the embedding workflow into clear steps: Read article
- Bringing chunking, storage and search back together: Read article
- Capture Intake as a separate module, less orchestration in the Express endpoint: Read article
Are you planning quota enforcement for your SaaS backend and considering how rate limiting and billing work together? Let us assess it together.



