Rate Limiter Plugin
Protects your application from abuse by limiting the number of requests a client can make within a specified time window. Supports both IP-based and custom key-based rate limiting.
Quick Start
import { Server } from "balda";
const server = new Server({
plugins: {
rateLimiter: {
keyOptions: { limit: 100 },
storageOptions: { windowMs: 60_000 }, // 1 minute
},
},
});
Configuration
IP-Based Limiting
When the app runs behind a reverse proxy, register the Trust Proxy plugin before the rate limiter so req.ip reflects the real client, not the load balancer.
rateLimiter: {
keyOptions: {
type: "ip",
limit: 100, // Requests per window
},
storageOptions: {
windowMs: 60_000, // Time window in ms (1 minute)
},
}
Custom Key-Based Limiting
Set type: "custom" and supply a key function:
rateLimiter: {
keyOptions: {
type: "custom",
key: (req) => req.rawHeaders.get("X-API-Key") ?? req.ip,
limit: 50,
},
storageOptions: {
windowMs: 60_000,
},
}
Storage Configuration
Storage is the second argument, not part of keyOptions. Passing it inline is ignored:
import { rateLimiter } from "balda";
rateLimiter(
{ type: "ip", limit: 100 },
{ type: "memory", windowMs: 60_000, maxKeys: 100_000 },
);
When registered as a plugin, the same two objects are keyOptions and storageOptions:
rateLimiter: {
keyOptions: { type: "ip", limit: 100 },
storageOptions: { type: "memory", windowMs: 60000 }
}
Usage
The limiter takes the same two arguments everywhere: rateLimiter(keyOptions, storageOptions).
Registering it as a plugin just splits them into keyOptions / storageOptions.
Global Rate Limiting
const server = new Server({
plugins: {
rateLimiter: {
keyOptions: { limit: 100 },
storageOptions: { windowMs: 60_000 }, // 100 requests per minute
},
},
});
@controller("/api")
export class ApiController {
@get("/users")
async getUsers(req: Request, res: Response) {
// Rate limited to 100 requests per minute per IP
const users = await getUsers();
res.json(users);
}
}
Route-Level Rate Limiting
import { rateLimiter } from "balda";
@controller("/api")
export class ApiController {
@get("/public", {
middleware: [rateLimiter({ limit: 1000 }, { windowMs: 60_000 })],
})
async publicEndpoint(req: Request, res: Response) {
// 1000 requests per minute
res.json({ message: "Public endpoint" });
}
@post("/auth/login", {
middleware: [
rateLimiter({ limit: 5 }, { windowMs: 15 * 60 * 1000 }), // 15 minutes
],
})
async login(req: Request, res: Response) {
// 5 requests per 15 minutes (prevent brute force)
const user = await authenticateUser(req.body);
res.json(user);
}
}
Custom Key Function
rateLimiter: {
keyOptions: {
type: "custom",
key: (req) => {
// Rate limit by user ID if authenticated, IP otherwise
const userId = req.user?.id;
return userId ?? req.ip;
},
limit: 100,
},
storageOptions: { windowMs: 60_000 },
}
Error Response
Rejected requests return statusCode (default 429) with a code and message:
// Response: 429 Too Many Requests
{
"message": "ERR_RATE_LIMIT_EXCEEDED",
"code": "RATE_LIMIT_EXCEEDED"
}
Both are configurable — message for humans, code for clients that branch on it:
rateLimiter({
type: "ip",
limit: 100,
message: "Slow down.",
code: "RATE_LIMITED",
statusCode: 429,
});
Common Patterns
API Endpoints
rateLimiter: {
keyOptions: { limit: 100 },
storageOptions: { windowMs: 60 * 1000 }, // 100 requests per minute
}
Authentication Endpoints
rateLimiter: {
keyOptions: { limit: 5 },
storageOptions: { windowMs: 15 * 60 * 1000 }, // 5 attempts per 15 minutes
}
Public Endpoints
rateLimiter: {
keyOptions: { limit: 1000 },
storageOptions: { windowMs: 60 * 1000 }, // 1000 requests per minute
}
Sensitive Operations
rateLimiter: {
keyOptions: { limit: 10 },
storageOptions: { windowMs: 60 * 60 * 1000 }, // 10 requests per hour
}
Complete Example
const server = new Server({
plugins: {
rateLimiter: {
keyOptions: { limit: 100 },
storageOptions: { windowMs: 60_000 },
},
},
});
@controller("/api")
export class ApiController {
// Global rate limit applies here
@get("/users")
async getUsers(req: Request, res: Response) {
res.json(await getUsers());
}
// Custom rate limit for auth endpoint
@post("/auth/login", {
middleware: [rateLimiter({ limit: 5 }, { windowMs: 15 * 60 * 1000 })],
})
async login(req: Request, res: Response) {
const user = await authenticateUser(req.body);
res.json(user);
}
// Custom rate limit by API key
@get("/premium", {
middleware: [
rateLimiter(
{
type: "custom",
key: (req) => req.rawHeaders.get("X-API-Key") ?? req.ip,
limit: 1000,
},
{ windowMs: 60_000 },
),
],
})
async premiumEndpoint(req: Request, res: Response) {
res.json({ data: "premium content" });
}
}
Environment-Based Configuration
const isProduction = process.env.NODE_ENV === "production";
const server = new Server({
plugins: {
rateLimiter: isProduction
? {
keyOptions: { limit: 100 },
storageOptions: { windowMs: 60_000 },
}
: undefined, // Disable in development
},
});
Custom Storage
For distributed systems, pass an atomic increment as the second argument. It must create a new
fixed window on the first call and return the current count plus the window's reset timestamp:
rateLimiter(
{ type: "ip", limit: 100 },
{
type: "custom",
windowMs: 60000,
increment: async (key, windowMs) => {
// Atomically increment in Redis, DynamoDB, etc.
const count = await redis.incr(key);
if (count === 1) await redis.pexpire(key, windowMs);
const ttl = await redis.pttl(key);
return { count, resetAt: Date.now() + (ttl > 0 ? ttl : windowMs) };
},
},
);
A ready-made Redis implementation ships with balda — it uses the built-in Bun.RedisClient on Bun
and dynamically imports ioredis on Node and Deno:
import { rateLimiter, redisRateLimitStorage } from "balda";
server.use(
rateLimiter(
{ type: "ip", limit: 100 },
redisRateLimitStorage({ url: process.env.REDIS_URL, keyPrefix: "rl:" }),
),
);
When custom storage throws, the limiter fails closed by default (returns the 429 response) since
a custom store is a shared external dependency. Pass failClosed: false to fail open instead, and
use onStorageError(error, key, windowMs) to observe outages:
rateLimiter({
type: "ip",
failClosed: false,
onStorageError: (err, key, windowMs) =>
logger.error({ err, key, windowMs }, "rate limit store down"),
});
Best Practices
- Use different limits for different endpoints - Auth endpoints need stricter limits
- Consider user tier - Premium users might get higher limits
- Monitor rate limit hits - Adjust limits based on actual usage
- Use distributed storage - In-memory storage doesn't work across multiple servers
- Provide clear error messages - Help users understand why they're being limited
- Watch storage failures -
onStorageErrorturns a silent outage into a log line