Quickback Docs

Rate Limit - Per-Operation Request Caps

Per-resource, per-operation rate limiting backed by Cloudflare's native Rate Limiting binding. Defaults apply to every resource automatically; override or opt out per-resource or project-wide.

Per-operation request caps for every CRUD endpoint. Backed by Cloudflare's native Rate Limiting binding — no extra storage, no third-party libraries.

Defaults

Every resource gets these limits automatically (per authenticated user, per minute):

OperationDefault
read1000 / 60s
create200 / 60s
update200 / 60s
delete200 / 60s

Numbers are loose enough that no legitimate user-driven traffic hits them, but tight enough to catch a runaway script before it burns the day's D1 budget. Override per-op or opt out wherever the defaults don't fit.

Override one operation

// features/claims/claims.ts
export default defineTable(claims, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  rateLimit: {
    delete: { limit: 5, period: 60 },  // tighter than the 200/60s default
  },
  // read / create / update keep shipped defaults
});

Disable rate-limiting on a resource

// features/telemetry/events.ts
export default defineTable(telemetryEvents, {
  rateLimit: false,   // high-volume sink, skip enforcement
});

Project-wide overrides

// quickback.config.ts
export default defineConfig({
  // ...
  rateLimit: {
    read: { limit: 500, period: 60 },   // tighter project-wide read default
  },
});

Disable globally

// quickback.config.ts
export default defineConfig({
  // ...
  rateLimit: false,  // e.g. when behind a WAF that handles request shaping
});

Configuration shape

rateLimit?: {
  read?:   { limit: number, period: 10 | 60 };
  create?: { limit: number, period: 10 | 60 };
  update?: { limit: number, period: 10 | 60 };
  delete?: { limit: number, period: 10 | 60 };
} | false
  • period must be 10 or 60 (Cloudflare binding constraint — anything else is a compile-time error).
  • limit is a positive integer.
  • Omit an op to inherit the project default (or shipped default).
  • Set false at resource or project level to opt out.

Resolution order

  1. Project rateLimit: false → no enforcement anywhere
  2. Resource rateLimit: false → no enforcement on this resource
  3. Resource per-op override
  4. Project per-op default
  5. Quickback shipped defaults (1000 / 200 above)

Default-using resources collapse onto two shared bindings (RL_1000_60, RL_200_60), so a project that never declares rateLimit still ships exactly two rate-limit bindings — not one per resource.

How requests are keyed

Each handler composes a key as <resource>:<op>:<userId>. For routes that opt into PUBLIC access (no userId), the key falls back to the client's cf-connecting-ip header, then to the literal 'anon' if that header is absent too.

This means:

  • A single user is rate-limited per resource per op — heavy read traffic on claims doesn't slow their update traffic on applications.
  • One noisy user can't starve another — keys are scoped by userId.
  • Anonymous traffic hitting PUBLIC routes is keyed by IP, which works behind a CDN but is trivially bypassed by a botnet — pair PUBLIC routes with auth-required mutations or a Cloudflare WAF rule for hostile traffic.

How limits compile

The compiler walks every resource, collects unique (limit, period) tuples, and emits one Cloudflare binding per tuple to wrangler.toml:

[[unsafe.bindings]]
name = "RL_1000_60"
type = "ratelimit"
namespace_id = "60001000"
simple = { limit = 1000, period = 60 }

[[unsafe.bindings]]
name = "RL_200_60"
type = "ratelimit"
namespace_id = "60000200"
simple = { limit = 200, period = 60 }

Each generated CRUD handler invokes c.env.RL_<L>_<P>.limit({ key }) between the auth gate and the access check. Hitting the limit returns:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json

{ "error": "Rate limit exceeded", "code": "RATE_LIMITED", "retryAfter": 60 }

Batch endpoints

Batch routes (POST /batch, PATCH /batch, DELETE /batch, PUT /batch) count as a single request against their op's limit, not N. A batch of 100 records consumes one token, not one hundred. This matches how most rate-limit configurations are actually written (caps on request volume, not row volume).

Actions

Custom actions (record-based and standalone) inherit rate limits without configuration:

  • Record-based actions (e.g. POST /api/v1/claims/:id/approve) inherit the resource's update (write) bucket. A user who hits the write limit on claims is also blocked from approve.
  • Standalone actions (e.g. POST /api/v1/twilioIncoming) inherit the project-default update bucket — they don't belong to a resource.

Override either with the action's rateLimit field:

// features/claims/actions/approve.ts
export default defineAction({
  description: 'Approve a claim',
  input: z.object({ claimId: z.string() }),
  access: { roles: ['admin'] },
  rateLimit: { limit: 5, period: 60 },   // tighter than resource update bucket
  execute: async ({ db, input }) => { /* ... */ },
});
// features/integrations/actions/twilioIncoming.ts
export default defineAction({
  description: 'Webhook from Twilio',
  path: '/twilioIncoming',
  method: 'POST',
  input: z.object({ /* ... */ }),
  access: { roles: ['PUBLIC'] },
  rateLimit: { limit: 1000, period: 10 },   // tolerate burst from Twilio
  execute: async ({ input }) => { /* ... */ },
});

Set rateLimit: false to opt the action out of enforcement entirely.

Key composition — actions get their own keyspace so a generic resource bucket and an action's bucket never share counters:

Action kindKey shape
Record-based<resource>:action:<actionName>:<userId|cf-connecting-ip|anon>
Standalonestandalone:<path>:<userId|cf-connecting-ip|anon>

The :action: infix is what keeps update (resource bucket) and approve (action bucket) from sharing counters when they happen to land on the same binding name.

Cardinality — how many bindings ship

Cloudflare bindings are sized at compile time (limit + period are baked in), so the compiler emits one binding per unique (limit, period) tuple, not one per resource. A project of 50 resources that all use the defaults still ships exactly two rate-limit bindings.

ConfigurationBindings emitted
All defaults2 (RL_1000_60 for reads, RL_200_60 for writes)
One project-wide read override (read: { limit: 500, period: 60 })3 (RL_500_60, RL_1000_60 ← only if some resource opts back into the default, RL_200_60)
Five resources tightening delete to { limit: 5, period: 60 }3 (defaults + RL_5_60 shared)
Ten distinct per-action overrides at unique tuples12 (defaults + ten action buckets)

Practical caps. Cloudflare workers have generous binding limits (hundreds), so this is unlikely to be a real ceiling — but it's the reason every distinct (limit, period) you write on a rateLimit config widens the binding set, even when the same resource declares the same tuple multiple times. Resource-level configs that share a tuple share a binding and simply produce different runtime keys.

Key cardinality is separate. Each binding holds an independent counter per key. A binding called via claims:read:user_123 and applications:read:user_123 consumes two separate counters in the same binding — fan-out cost lives in Cloudflare's distributed counter store, not in your binding count. The user-perceivable limit is "per (resource, op, user)", regardless of how many resources share the binding.

Runtime support

RuntimeRate limiting
cloudflareBacked by Cloudflare's native Rate Limiting binding
bunIn-process fixed-window counter (src/lib/rate-limit-memory.ts). Same generated check code, same 429 response — single-instance only

On Bun, the compiler emits src/lib/rate-limit-memory.ts and wires up a per-binding shim in the server's fetch handler. Each RL_<limit>_<period> shim delegates to a process-memory token bucket, so the same c.env.RL_*.limit({ key }) call site that targets the Cloudflare binding works unchanged.

Multi-instance Bun deployments need either a load balancer with sticky sessions (so the same user keeps hitting the same instance and its bucket) or the Cloudflare runtime (which has cross-instance counters out of the box). The in-memory shim does not coordinate across processes.

Compile-time validation

The compiler errors on:

  1. period other than 10 or 60
  2. Non-integer or non-positive limit
  3. Unknown ops (e.g. list instead of read)

Disabling vs. tightening — when to use which

GoalConfiguration
One op needs a tighter cap (e.g. delete)Resource override on that op only
One op needs a looser cap (high-volume reads)Resource override raising the limit
One resource is a high-volume sinkrateLimit: false on the resource
Whole project is behind a WAFrateLimit: false at project level
App-wide tighter default (e.g. expensive reads)Project-level override of that op

On this page