Edit this page

Bounded by construction

Every route and handler has a static bound: the most it can use of each resource, whatever the input or data.

ResourceWhat counts
readsRows, index entries, and counters read
writesRows, index entries, and counters written
opsPlan operations and expression nodes evaluated
bytesReadBytes read from storage
effectsEvents, jobs, messages, sends, and change triggers released
callsIntegration calls
memoryBytes of values held while running: every value read, copied, or built, bound or not, at its in-memory size

How bounds are computed

The checker folds over the plan:

  • Sequences add, and branches take the maximum.
  • Loops multiply the body's usage by the list's maximum length. Lists get a maximum from where they come from: a query's limit, an array field's max, a list literal, or map/filter over those. A $.loop multiplies one pass by its declared max, and its state must keep the same type, so it can't grow from pass to pass.
  • Queries use limit reads, and limit × max row size bytes.
  • Writes use one for the row, plus each index entry and counter it maintains (twice for updates, which may move an entry).
  • Sorting uses n × ⌈log₂ n⌉ comparisons.
  • Pattern matching (.matches()) uses ops for the text's maximum length times the pattern's size in states (about one per character, class, quantifier, and alternative, with counted repetitions like {0,50} multiplied out), so the text needs a .max(), and patterns over 1,024 states are rejected. A 254-character email check uses about 70 ops.
  • in against a list of constants uses one more op per 64 constants.
  • Chains of handlers are bounded too: each run has its own limits, and handlers that could set each other off without end are rejected as trigger cycles.

Everything traces back to a declaration: .max(...) on strings, ints, and arrays, limit on queries, and max on $.loop. That's why a string field without a maximum isn't a field.

When a bound is too high

✖ over_budget at app.ts:41:3 (routes[2].plan)
  route "bulkComplete" can use up to 75 writes in the worst case, over the limit of 50.
  Lower the bounds that drive it (query limits, list sizes) or split the work.

The fix is always one of:

  • Lower a bound, such as a smaller limit or a smaller max on a list input.
  • Move the work to a job: a queue job, a scanning schedule, or a workflow. Each run is bounded against the larger job limits.
  • Remove amplification: an index or counter you don't need adds writes to every change.

The meter

At runtime every charge goes through a meter configured with the same limits. Static analysis promises the meter never trips. If it ever does (a bug), the request stops with budget_exceeded and its transaction rolls back. You never get a half-done, over-budget request.

Why this matters

Bounds make a shared runtime safe. A runtime can admit a request knowing its worst case, schedule work fairly, and bill predictably. One app's bad loop or heavy query can't starve the others, because it can't be expressed.

That's what lets many apps share the same processes and database instead of each getting its own isolated runtime, and why each app costs far less to run. See Shared infrastructure and isolation.