Documentation

Advanced

Rate Limiter

How VitNode splits API rate limits into read, write, sign-in and content-write budgets under a global safety cap, with Redis-shared counters and 429 responses that carry Retry-After.

VitNode limits API requests per client IP, and per member once they sign in. Reads, writes, sign-in attempts and public content writes each get their own budget, and one global safety cap per IP sits above them all. A request over any budget gets 429 Too Many Requests with a Retry-After header.

How the budgets work

Every request spends points from the budgets that apply to it, in this order, and stops at the first one that is used up:

BudgetSpent byDefaultConfig key
Safety capEvery request, including OPTIONS preflights1000 / 60 srateLimiter.safety
ReadGET and HEAD300 / 60 srateLimiter.read
WritePOST, PUT, PATCH, DELETE and any other method60 / 60 srateLimiter.write
Sign-inPOST to the sign-in, sign-up, password and passkey routes, plus write10 / 60 srateLimiter.auth
Content writesPOST and PATCH under a plugin's /content/, outside /admin/, plus write20 / 60 srateLimiter.contentWrite

So a sign-in spends the safety cap, the write budget and the sign-in budget. A GET spends only the safety cap and the read budget. Each budget is spent at most once per request.

The sign-in budget covers:

  • POST /users/sign_in and POST /users/sign_up
  • POST /users/reset-password and POST /users/change-password
  • POST /users/verify-email and POST /users/verify-email/resend
  • POST /users/passkeys/sign-in and POST /users/passkeys/admin-sign-in

Who a budget belongs to

BudgetGuestSigned-in member
Safety cap, sign-inIP addressIP address
Read, write, content writesIP addressThe member

A member's read and write budgets follow them, not their network. Fifty colleagues behind one office IP each get their own 300 reads once signed in, and a member who switches from Wi-Fi to mobile data keeps the budget they had. The safety cap stays per IP, so many accounts on one address still share its 1000 requests. The sign-in budget stays per IP because a sign-in request has no member yet.

VitNode only trusts a session the API has verified. A request with a forged or expired session cookie is counted against its IP address. Set rateLimiter.perUser: false to count every budget per IP address, as older versions did.

IPv6 visitors are counted per /64 network, not per address. An ISP usually hands one person a whole /64, and we'd rather not give them 18 quintillion fresh budgets.

Disabled in development

Every budget is skipped when NODE_ENV=development, so local clicking never hits a 429.

Why reads and writes are separate

The router preloads a page when a visitor rests the pointer on a link. A preload runs the page's loader, and the loader calls the API. Those are real GET requests that the visitor never asked for.

With one shared budget, a visitor who hovered across a long forum list could use up the minute before pressing Post comment. With separate budgets, preloads can only use up the read budget. The comment still has its own 60 writes, and the sign-in and content-write budgets still apply to it.

The safety cap is the wall behind both. It is set well above read plus write (300 + 60), so normal use never reaches it. It stops a client that would otherwise spend every budget in parallel, and it also counts OPTIONS preflights, which no other budget sees. A preflight is counted but never refused: once the cap is used up, the request that follows the preflight gets the 429, so the browser can read it instead of reporting a CORS error.

The defaults leave room for a busy reader: a server-rendered page with a few queries costs 3 to 6 reads, and the query cache answers repeat visits without a request. Writes stay low because a person rarely submits more than one form a second, and a script that does is what the budget is for.

Change a budget

Set only the budgets you want to change in apps/api/src/vitnode.api.config.ts. Each one falls back to its own default for any option you leave out:

apps/api/src/vitnode.api.config.ts
import { buildApiConfig } from "@vitnode/core/vitnode.config"

export const vitNodeApiConfig = buildApiConfig({
  rateLimiter: {
    read: { points: 600 }, // a busy reading site
    write: { points: 30, duration: 60 },
    auth: { points: 5, duration: 60, blockDuration: 300 },
  },
})

blockDuration keeps a client blocked for that many seconds after it goes over, instead of until the window ends.

Migrate from the single global limit

Older configs set points and duration at the top of rateLimiter. Those keys configured a cap on every request, which is what safety is now, so VitNode keeps them working as the safety cap with the old defaults (80 requests per 60 seconds). The read and write budgets apply underneath.

apps/api/src/vitnode.api.config.ts
rateLimiter: {
  points: 120, 
  duration: 60, 
  safety: { points: 120, duration: 60 }, 
},

Nothing gets looser on its own: an old config keeps exactly the cap it had and logs a deprecation warning at boot. To get the new defaults, delete the top-level keys. With them gone, the safety cap is 1000 and reads get 300 of their own.

Setting both the old top-level keys and safety stops the API at boot with an error, because both name the same limiter. auth and contentWrite work as before, and their Redis counters carry over a deploy.

429 response format

When a client goes over any budget, the API returns:

HTTP/1.1 429 Too Many Requests
Retry-After: 24
X-RateLimit-Category: read
Content-Type: application/json

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

X-RateLimit-Category names the budget that refused the request: safety, read, write, auth or content-write. Both headers are listed in the CORS exposeHeaders, so a web app on another origin can read them; VitNode adds them to your own cors options if you pass any. In the browser, VitNode shows one toast that uses Retry-After for its countdown, even when several requests fail at once.

Debug unexpected 429 errors explains how to find which budget a real visitor ran out of.

Distributed rate limiting (Redis)

  • Without Redis: each API instance counts in its own memory.
  • With Redis: all API instances share the counters, so a visitor gets the same budget whichever instance answers. Each budget has its own key prefix, so the counters never mix.

If Redis stops answering, each instance falls back to counting in memory until it returns, so requests keep flowing and limits keep holding.

apps/api/src/vitnode.api.config.ts
export const vitNodeApiConfig = buildApiConfig({
  redis: process.env.REDIS_URL
    ? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD }
    : undefined,
})

Real visitor IPs for server-rendered pages

When your web app renders a page on the server, it calls the API itself - so the API sees the web server's address, not your visitor's. Every server-rendered request then lands in the same bucket, and one busy page fills it fast.

The API never believes a plain X-Forwarded-For header (anyone can type one). Instead, the web server works out the visitor's address, signs it with a key derived from your CRON_SECRET - the secret the web app and the API already share - and the API checks that signature before trusting it.

  • Set the same CRON_SECRET in both apps (you need it there anyway).
  • Tell each app how many proxies sit in front of it with VITNODE_TRUSTED_PROXY_HOPS. It skips exactly that many hops from the right of X-Forwarded-For and ignores everything further left, because a visitor can write whatever they like there. The API reads it too, for browser calls that reach it through your proxy - in a split deployment, set it to the proxies in front of the API, and leave it at 0 if the API is only reachable from the web server.
  • The API trusts the forwarded address only when the signature matches and is less than 5 minutes old.
  • While CRON_SECRET is unset or still a published placeholder, nothing is trusted: the API keys every request on the connection address.
SetupVITNODE_TRUSTED_PROXY_HOPS
Web app exposed directly, no proxy (default)0
One reverse proxy (Nginx, Caddy, a load balancer, Vercel)1
A CDN in front of your reverse proxy2
Count your proxies, not your hopes

A number higher than the proxies you actually run lets visitors choose their own address again. With the default 0, every server-rendered request behind a proxy shares the proxy's bucket, so the limit is safe but tight.

One secret, two jobs

Anyone holding CRON_SECRET can trigger cron jobs and pick any IP address the rate limiter will believe. Use a long random value and rotate it in both apps together.

No request header can skip or refill a budget. A header such as X-Prefetch: true is ignored, because any client can send it.

rateLimiter options

Every budget takes the same options: points (requests per window), duration (window length in seconds), blockDuration (seconds to stay blocked after going over, 0 by default) and execEvenly / execEvenlyMinDelayMs (delay allowed requests so they spread across the window).

Prop

Type

Learn more