Vision
Vision Server

Rate Limiting

Per-endpoint and module-level rate limiting with the rateLimit() helper

Rate Limiting

Vision Server ships a small rateLimit() helper that plugs into Elysia's beforeHandle hook. It works on individual routes, whole modules, or the entire app.

Quick Start

import { createModule, rateLimit } from '@getvision/server'
import { z } from 'zod'

export const usersModule = createModule({ prefix: '/users' })
  .post(
    '/',
    async ({ body }) => ({ id: crypto.randomUUID(), ...body }),
    {
      body: z.object({ name: z.string(), email: z.string().email() }),
      beforeHandle: [rateLimit({ requests: 5, window: '15m' })],
    }
  )
  • requests — max number of requests allowed per window.
  • window — time window. Supported formats: '30s', '15m', '1h', '2d', or raw milliseconds as a string ('900000').

Exceeding the limit returns a 429 Too Many Requests with a JSON body and RateLimit-* / Retry-After headers:

{ "error": "Too Many Requests", "retryAfter": 900 }

Module-level rate limiting

Use .onBeforeHandle on the module to apply the limiter to every route it defines:

export const adminModule = createModule({ prefix: '/admin' })
  .onBeforeHandle(rateLimit({ requests: 10, window: '30s' }))
  .get('/stats', handler)
  .get('/sessions', handler)
  .delete('/sessions/:id', handler)

Custom key generator

By default the limiter keys on the client IP (falling back to User-Agent). Provide keyBy to scope limits differently — for example, per-authenticated-user:

rateLimit({
  requests: 100,
  window: '1h',
  keyBy: ({ request }) => request.headers.get('x-user-id') ?? 'anonymous',
})

Custom message

rateLimit({
  requests: 3,
  window: '1m',
  message: 'Slow down — you can try again in a minute.',
})

The message replaces the default "Too Many Requests" error field.

Distributed store (Redis, etc.)

The default store is in-memory and per-process. For multi-instance deployments you need a shared store. Vision exposes a minimal RateLimitStore interface — plug in any backend you like (Redis, Upstash, Cloudflare KV, …):

import { Redis } from 'ioredis'
import type { RateLimitStore } from '@getvision/server'
import { rateLimit } from '@getvision/server'

const redis = new Redis(process.env.REDIS_URL!)

const redisStore: RateLimitStore = {
  async increment(key, windowMs) {
    const now = Date.now()
    const bucketKey = `rl:${key}:${Math.floor(now / windowMs)}`
    const resetAt = Math.floor(now / windowMs) * windowMs + windowMs

    const multi = redis.multi()
    multi.incr(bucketKey)
    multi.pexpire(bucketKey, windowMs)
    const [[, count]] = (await multi.exec()) as [[null, number], [null, number]]

    return { count, resetAt }
  },
}

.post('/', handler, {
  beforeHandle: [
    rateLimit({ requests: 50, window: '1h', store: redisStore }),
  ],
})

In Docker or single-node deployments the default MemoryRateLimitStore is fine. For auto-scaling or serverless you must use a shared store, or each instance will apply its own independent limit.

How it works

rateLimit() returns an Elysia beforeHandle hook. When the limit is exceeded it short-circuits with a raw Response(429, ...); Elysia skips the handler and response schema. Vision's tracing hooks detect the raw Response and log code=429 correctly.

Headers included on rejected responses:

  • RateLimit-Limit — configured requests
  • RateLimit-Remaining — how many are left (0 on rejection)
  • RateLimit-Reset — seconds until the window resets
  • Retry-After — same value, standard HTTP header

Tips

  • Per-endpoint — set different limits per route: stricter for auth/signup, looser for reads.
  • Observability — rate-limit hits appear in the Vision Dashboard with full trace context (IP, path, key).
  • Composition — stack beforeHandle hooks freely: [rateLimit(...), authMiddleware, ...].