The table
The three memory caps (single filter value, cumulative filter work, output size)
are tunable per host. Everything else is compiled in. See
Tuning the memory budget.
Iteration budgets
1,000 items per loop
An{each} over a longer collection renders the first 1,000 items and records a
warning naming both numbers:
{repeat n} is capped at the same 1,000, but by clamping n rather than by
truncating a collection, and it records nothing at all. So a quiet build is
not evidence that no repeat was clamped.
The warning is emitted in both build modes. The dropped rows leave no trace in
the page, so a build that quietly loses a third of a product list would otherwise
report itself clean.
1,000,000 iterations per render
The per-loop cap alone does not stop multiplicative blowup. Three nested{repeat 1000} blocks are 1,000,000,000 bodies while never crossing the
per-construct limit, so there is a second, global counter across every {each}
and {repeat} in one render.
Once it is spent, loops stop iterating and emit nothing further. One warning is
recorded, latched so it appears exactly once:
Memory budgets
16 MiB of output
The iteration budget bounds loop count, not loop body size. A large static body repeated within the iteration budget, or duplicated through slots, could still build a multi-gigabyte string. Once 16 MiB has been emitted, rendering stops emitting and records:8 MiB per filter value, 64 MiB of filter work
These two are the only budgets that are errors, and they fail the build. Several filters expand their input.json roughly doubles a string, because it
re-quotes and re-escapes something already quoted and escaped, and HTML escaping
can grow one 6x. An unbounded chain is therefore exponential: about fifty links
turn a six-byte value into tens of gigabytes.
Nothing else catches that shape. The output budget bounds what is emitted and
the iteration budget bounds how often a loop runs, but a filter chain emits
nothing until it finishes, so neither ever sees an intermediate value.
-
8 MiB per value puts the doubling out of reach. It is per-value rather
than cumulative: growth is multiplicative, so the first intermediate to cross
the cap ends the chain before the next doubling.
- 64 MiB cumulatively closes the other shape, many chains each producing values just under the per-value cap.
Tuning the memory budget
Those three are the fields ofMemoryBudget, which a host can set per render.
MemoryBudget::scaled clamps every cap to at least one byte, so a budget is
never zero. A zero cap would refuse the first byte of every render, which reads
as “the compiler is broken” rather than “the budget is too small”.
Depth ceilings
Render depth 96
The renderer recurses through nested elements, component includes and layout includes. A single file’s nesting is already bounded by the parser at 64, but nothing else bounds a chain of includes (A includes B includes C, and so on), and the renderer’s stack frames are large. Past 96, the subtree renders as empty and one warning is recorded:Parser nesting depth 64
The parser refuses to descend past 64 levels of nested constructs in one file. That is an error, and because the parser then stops mid-construct it usually arrives with a cascade of follow-on diagnostics:Lexer ceilings: 2,000,000 tokens and 512 modes
Both refuse the file outright, before the parser sees anything. The token cap bounds the memory a single source file can turn into. The mode stack bounds nesting of the lexer’s own contexts, where${, a backtick, a tag
and each block tag all push a mode. It bounds the work a 600 KB file of
${` costs, rather than producing a multi-million-token stream the parser
then walks only to reject.
Import chain depth 128
A chain of N imported files recurses N frames, each running a full parse, and a stack overflow is a process abort rather than a catchable error. This was measured: on a 2 MiB stack, 500 files were fine and 5,000 overflowed. The cap turns the abort into a cleanCircularImport diagnostic:
Security walk depth 512, {fetch} walk depth 512
Both are defence in depth. The AST a walk receives is already depth-bounded by
the parser, but ast::Document is a public type and both walks are reachable
from public functions, so a hand-constructed or deserialised document arrives
without ever having passed the parser.
The security walk returns an error past its ceiling. The {fetch} URL walk stops
descending and returns what it has, silently, because it feeds a pre-fetch
optimisation rather than a correctness decision.
Stack: 2 MiB segments with a 128 KiB red zone
On native targets the parser and renderer grow the stack on demand, in 2 MiB segments, whenever fewer than 128 KiB remain. The depth ceilings above are therefore the real bound, rather than whatever stack a caller happened to provide. A tokio SSR worker’s default stack is not enough for 96 frames of element rendering. On wasm there is no way to grow the stack, so this is a no-op and the depth caps alone keep it safe. Nothing changes about the numbers.Section and block ceilings
These apply when a host is rendering editor-driven sections, and they are enforced at merge time on saved state, which is untrusted JSON.- Block merge depth 64. Nested blocks past that depth are dropped, silently.
- 200 blocks per type, when the schema declares no
max. A schemamaxis an explicit per-type cap and wins where present; absent it, the editor allows any number, so this is a hard ceiling against a pathological saved state rather than a product decision.
Smaller ceilings
fixed(n)precision 100.nis a template literal, not a rendered value, so this guards against a typo rather than being a trust boundary. Without itfixed(1000000)allocates a megabyte per call for nothing. Larger values are clamped, silently.{html ... sanitize}keyword lookahead, 4,096 characters. The lexer scans forward from{htmlfor thesanitizekeyword, stopping at the first}. Bounding it keeps the scan O(1); scanning to the next}would make a file of many unterminated{htmlstarts quadratic. An opening tag is short, so the window is far larger than any real one.- 1,000 accumulated diagnostics. Past that, pushing a diagnostic is a no-op. A pathological input can raise one per token, so without a ceiling the accumulator is itself an unbounded allocation. If you see exactly 1,000 diagnostics, assume there are more.
Things that are not limits
- There is no per-layout depth ceiling.
LayoutDepthExceededis a declared error code that the compiler never emits; layout chains are bounded by the render depth of 96 like everything else. - There is no infinite-loop detector.
InfiniteLoopis likewise declared and never emitted. Loops are bounded by the two iteration budgets instead. - A recursive component does not error. The renderer keeps a rendering stack and refuses to re-enter a file already being rendered, which falls back to the unresolved-component placeholder. No diagnostic.
How to tell a budget was hit
In rough order of usefulness:slurp build -v. Every budget above that records anything records it here. Without-v, only the errors print.slurp validate --warningsfor the parse-time and lex-time ceilings. Render budgets do not appear, because validate does not render.- The
ErrorAccumulator, if you are embedding.compileandcompile_with_registryreturn(String, ErrorAccumulator); every budget diagnostic carriesErrorCode::IterationLimitExceeded, whatever budget it came from, with a message naming the specific one. Filter for that code and read the message. See Embedding with Rust. - The WASM
renderresult. Budget diagnostics are reported in both modes, unlike the two development-only advisories, sorenderandrender_devboth surface them. See the JavaScript API. - Output length. A page that ends mid-tag at almost exactly 16 MiB hit the output budget.