# Rate limits on Workers

A Worker's instances share no memory, so on Cloudflare Workers your rate
limits need a store that every instance can reach. This topic is how
that store works ([Rate limits](/docs/rate-limits.md) has the limits themselves).

## Choosing the store

`rate_limits: { store: }` in `application { }` names one connection,
with `Driver.redis` or `Driver.cloudflare_rate_limits`, and nothing else
(`rate_limits.store_invalid`).

A deployable built for Workers that serves an API needs a store
(`rate_limits.store_required`). One that serves no API has no routes to
limit, and counts the requests it refuses beside its health checks in
its own memory.

## Cloudflare's binding: `Driver.cloudflare_rate_limits`

`Driver.cloudflare_rate_limits` runs only on Workers, so a native
deployable that serves an API with that store is refused
(`deployable.target_unsupported`), naming Redis. Its `namespace:` is a
whole number of at least 1.

- **One binding for each limit.** A binding counts one limit over one
  window, so each limit your app counts is a binding of its own. It is
  named for the connection and the limit, such as `LIMITS_120_PER_60S`,
  and numbered from `namespace:` in order of window and then of
  requests. `jadpo build` writes each into the Wrangler file as
  `ratelimits: [{ name, namespace_id, simple: { limit, period } }]`.
  Adding a limit can renumber the ones after it, which loses at most a
  window's counts, and those are harmless to lose.
- **Windows** of 10 and 60 seconds. The default limits are a minute's
  (600 for a signed-in caller, 120 for an address). A route or an API that
  writes any other window is refused where it wrote it
  (`rate_limits.window_unsupported`).
- **A deployable's bindings.** Each is numbered from every limit the app
  writes, so a limit has the same namespace in every deployable, and a deployable's
  Wrangler file names only those its own routes count.
- **`Retry-After`** is the window's whole length, since the binding says
  only whether a request is within the limit, not when its window ends.

## What the binding promises

Treat a limit in this binding as rough: it refuses a client that keeps on
asking, but well past the number you wrote. Here is what it did, measured once, from one location (London, LHR), on 2026-11-04, with one client and no other traffic on the account:

| Limit | What happened |
|---|---|
| 5 in 10 seconds, one request after another | 24 passed before the first 429 |
| 10 in 60 seconds, one after another | 49 passed before the first 429 |
| 120 in 60 seconds, one after another | 200 passed with none refused, when the check stopped |
| 5 in 10 seconds, 60 sent at once | 44 passed, 16 refused |

Once its window had passed, the 5-in-10-seconds route answered again. Each
429 gave the window's length in `Retry-After`. Those are one run's numbers,
not a promise: they show how far past a limit a client can get, and it may
differ elsewhere and under other traffic.

Cloudflare documents the binding as approximate, and these are its other
terms. A limit on Workers is only as strong as this:

- **Per location.** Cloudflare says each of its locations keeps its own count,
  and they don't add up to one worldwide limit, so `requests: 5` applies
  roughly in each location a client's requests reach. This hasn't been
  measured from a second location.
- **Approximate.** Each check reads a count kept where the Worker runs, and
  counts update in the background, so requests pass the limit, as the table
  shows. It isn't an accounting system. Cloudflare's own answer for a strict
  limit is a limiter on a Durable Object, which Jadpo doesn't build.
- **An address can be many people.** Cloudflare warns against keying a limit
  on an address, since many users can share one on mobile networks. Prefer
  `RateLimitKey.caller` where callers sign in, and don't set a limit by
  address so low that a shared address trips it.
- **A namespace id is the whole account's.** Cloudflare says two bindings with
  one `namespace_id` share their counts for a key, across Workers, but two
  deployables of one app didn't in a single measurement (LHR, 2026-11-04):
  three times, one was refused after 87, 81 and 10 requests at 10 in 60
  seconds and the other then answered three requests. Don't rely on either
  behaviour. Give another app in the account a `namespace:` far enough from
  this one's that its numbers don't meet them.

So use the binding to shed abuse, not to enforce a total
([What a limit doesn't promise](/docs/rate-limits.md#what-a-limit-doesnt-promise)).

## When the binding can't be asked

- **Falling back.** If the Worker hasn't got the binding, or the binding
  throws or rejects, the request is counted in the isolate's own memory,
  so no request fails for it.
- **Not waited for.** A binding that hasn't answered within 500 ms isn't
  waited for: the request is counted in the isolate's memory, as for one
  that throws. The binding answers from a count kept where the Worker
  runs, so one slower than that is in trouble, and a request isn't held
  up by it.
- **Logged once.** Each binding that can't be asked is logged once for
  each isolate, as `rate_limits.fallback`, with the binding's name and
  why (`missing`, `failed` or `timed_out`), never a key. An operator sees
  that a Wrangler file lost its bindings, without a log line for every
  request.

## A Redis store on Workers

Until Redis's driver comes with caches, a deployable whose store is
Redis counts in its own memory, the same fallback as when the store
can't be reached. A Worker's instances share no memory, so a deployable
built for Workers that serves an API with a Redis store is
`rate_limits.counted_in_memory`. It is a judgement call, since Jadpo has
no warnings. You answer it with Cloudflare's binding, or with a
`decline` line above the deployable that keeps Redis and counts in
memory until its driver comes. A native deployable with a Redis store is
unaffected.

The windows and the Wrangler entries aren't yet checked against the real
edge: the workerd pinned in `tools/fixtures/workers/` has no binding of
its own, and its types give only `limit({ key })` and `success`
([What is built](/docs/built-in-services-built.md)).
