Skip to content

Rate Limiting

CruzJS provides distributed rate limiting through the RateLimitModule. On Cloudflare, rate limit state is stored in KV for cross-isolate consistency. Container deployments use Redis.

Register the RateLimitModule in your application:

import { RateLimitModule } from '@cruzjs/core/rate-limiting';
export default createCruzApp({
modules: [RateLimitModule],
});

All backends implement the same interface:

interface RateLimitAdapter {
hit(key: string, limit: number, windowSeconds: number): Promise<RateLimitResult>;
reset(key: string): Promise<void>;
getRemaining(key: string, limit: number, windowSeconds: number): Promise<number>;
}
PlatformAdapterStorage
CloudflareCloudflareKVRateLimitAdapterKV namespace with TTL-based windows
Docker / ContainersRedis-based adapterRedis INCR + EXPIRE

The KV adapter uses a read-modify-write pattern: it reads the current count, increments it, and writes back with a TTL matching the window duration.

The RateLimitService wraps the adapter with a convenient API:

import { Injectable, Inject } from '@cruzjs/core/di';
import { RateLimitService } from '@cruzjs/core/rate-limiting';
@Injectable()
export class MyService {
constructor(
@Inject(RateLimitService) private readonly rateLimit: RateLimitService,
) {}
async processRequest(userId: string) {
const result = await this.rateLimit.check(
`api:${userId}`, // key
100, // limit
60, // window in seconds
);
if (!result.allowed) {
throw new Error(`Rate limit exceeded. Retry after ${result.retryAfter}s`);
}
// Process the request...
}
}
type RateLimitResult = {
allowed: boolean; // Whether the request is within limits
remaining: number; // Requests remaining in the current window
limit: number; // Total requests allowed per window
retryAfter: number; // Seconds until the window resets (0 if allowed)
};

When applying rate limiting, return standard headers so clients can track their usage:

HeaderDescription
X-RateLimit-LimitMaximum requests allowed in the window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetSeconds until the window resets
const result = await rateLimit.check(`api:${userId}`, 100, 60);
return new Response(body, {
headers: {
'X-RateLimit-Limit': String(result.limit),
'X-RateLimit-Remaining': String(result.remaining),
'X-RateLimit-Reset': String(result.retryAfter),
},
});

Inject RateLimitService and check before processing:

import { RateLimitService } from '@cruzjs/core/rate-limiting';
import { TRPCError } from '@trpc/server';
const rateLimitedProcedure = protectedProcedure.use(async ({ ctx, next }) => {
const rateLimit = ctx.container.get(RateLimitService);
const result = await rateLimit.check(
`api:${ctx.user.id}`,
100,
60,
);
if (!result.allowed) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: `Rate limit exceeded. Retry in ${result.retryAfter} seconds.`,
});
}
return next();
});

The same pattern applies in route loaders and actions:

export async function action({ request, context }: ActionFunctionArgs) {
const rateLimit = context.container.get(RateLimitService);
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown';
const result = await rateLimit.check(`auth:${ip}`, 5, 900); // 5 per 15 min
if (!result.allowed) {
throw new Response('Too many requests', {
status: 429,
headers: {
'Retry-After': String(result.retryAfter),
},
});
}
// Process the action...
}
ProcedureTypeDescription
rateLimit.checkqueryCheck rate limit status for a key (admin)

Use consistent key prefixes to organize rate limits:

// By category and identifier
`auth:${ip}` // Auth endpoints by IP
`api:${userId}` // API calls by user
`upload:${userId}` // File uploads by user
`webhook:${orgId}` // Webhook dispatches by org