better-push
Operate in production

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.

src/push.ts
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-pending is 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

On this page