Rate limiting
Per-user limits on the mounted router - on by default, shared through the cache, and failing open.
The router is rate limited by default. A feed that polls, an inbox that paginates, and a preferences screen that saves are all endpoints an authenticated client can call in a loop, and each one costs a query on your primary database.
export const push = betterPush({
database: drizzleAdapter(db),
providers: [webPush({ vapid })],
session: getSession,
// Defaults shown. `rateLimit: false` disables it entirely.
rateLimit: {
max: 120, // reads per user per window
window: "1m",
writes: { max: 30 }, // tighter, independent budget for mutations
maxStreams: 5, // concurrent SSE streams per user per instance
},
});How it counts
One fixed window per user per bucket. GET/HEAD requests count against the
read budget; everything that changes state - registering or deleting a device,
mark-read, read-all, preference writes - counts against the writes budget. The
two are independent, so a burst of writes cannot exhaust a user's ability to
read their feed.
writes inherits window unless it names its own.
Where the counters live
With a cache configured, counters are one incr in the cache, so
the budget is shared across every instance: 120 a minute means 120 a minute for
your deployment.
Without one, the same fixed window lives in the process. The budget is then per instance, so N replicas allow N times the limit. That is a documented degradation rather than a silent difference - if the number matters to you, configure a cache.
There is deliberately no bp_rate_limit table. A database write per
30-second feed poll per user is more write traffic than the notification data
itself, which is the same reason better-push has no request log.
The 429
HTTP/1.1 429 Too Many Requests
retry-after: 42
x-ratelimit-limit: 120
x-ratelimit-remaining: 0
x-ratelimit-reset: 42
{"error":{"code":"RATE_LIMITED","message":"too many requests; retry in 42s"}}The body uses the same envelope as every other router error. The client hooks treat it as a failed request and back off, so an over-eager tab recovers on its own.
It fails open
If the cache cannot serve a counter, the request is allowed and a warning is logged. A rate limiter that fails closed turns a brief Redis outage into a total one, which is a worse failure than the one it prevents.
What it does not do
Anonymous traffic is not rate limited. Limiting by IP means trusting
X-Forwarded-For, and trusting that header correctly needs a proxy-trust
configuration - which hop to believe, which networks are yours - that better-push
is not in a position to get right on your behalf. Getting it wrong is worse than
not doing it: one forged header and an attacker either evades the limit or locks
out a shared NAT.
Unauthenticated requests are already answered 401 by the session gate, so they
cost one session lookup. If you need protection below that line, your proxy, CDN
or WAF is where it belongs, and it is where you can express it properly.
Two routes are outside the limiter by design:
POST {basePath}/_internal/run-pendingis matched before the session resolves and has no user to be keyed on. Its shared secret is its gate.- The studio is an admin surface behind your own admin auth, and a limiter there would mostly rate-limit an operator during an incident.
Streams
maxStreams caps how many concurrent SSE streams one user
may hold on one instance; past that the route answers 429. It is a resource
bound rather than an abuse control - each stream is an open connection and a bus
subscription - and five is enough for any realistic number of tabs.
rateLimit: false removes this cap along with everything else.
Rate-limit options
Prop
Type