Fixing Maximum Call Stack Size Exceeded in Wasm

This page answers one task: a WebAssembly call fails with RangeError: Maximum call stack size exceeded (Firefox: too much recursion, Safari: call stack exhausted) — typically on large inputs — and you need to know which stack overflowed and how to stop it happening.

Prerequisites

  • [ ] A reproducible input that triggers the error.
  • [ ] A build with function names, so the stack trace shows which functions recurse.
  • [ ] Access to the source and its build flags (Rust, C/C++, or another compiled language).

Two stacks, two different failures

WebAssembly code uses two stacks. The native stack belongs to the engine: every Wasm function call pushes a frame holding its locals and operands, just as JavaScript calls do, and its size is fixed by the browser — typically around 1 MB on the main thread, sometimes less in workers. When deep recursion exhausts it, the engine throws the familiar RangeError that JavaScript developers know from infinite recursion. The other is the shadow stack: a region of linear memory that C, C++ and Rust compilers use for anything whose address is taken or that is too large for locals — arrays, structs passed by reference. Its size is set at link time (Emscripten defaults to 64 KB, Rust’s toolchain to 1 MB), and overflowing it does not throw RangeError; it silently overwrites the data below it or traps on an out-of-bounds access.

This page covers the RangeError — the native stack. If the symptoms are corrupted data or memory traps instead, the shadow stack is the likely culprit, as described in fixing stack overflow in Wasm.

The two stacks a compiled Wasm function uses Each call pushes a frame on the engine's native stack, which holds locals and operands and is limited by the browser. Address-taken and large values live on the shadow stack in linear memory, sized at link time. Deep recursion exhausts the native stack and throws RangeError; large frames can exhaust either. native stack (engine) frames, locals, operands — ~1 MB limit overflow → RangeError Maximum call stack size exceeded shadow stack (linear memory) arrays, address-taken values overflow → corruption or trap sized at link time heap (linear memory) malloc / Box / Vec

Step 1 — confirm it is recursion depth

Read the stack trace in the error. A native-stack overflow shows the same function, or a small cycle of functions, repeated thousands of times — a recursive descent parser, a tree walk, a flood fill, a recursive JSON or AST visitor. Check the input: deeply nested JSON, a long linked list, a degenerate tree, a large image region. Measure the depth at which it fails by feeding inputs of increasing nesting; it is usually a few thousand to a few tens of thousands of frames, depending on frame size and browser.

Step 2 — turn recursion into iteration

The robust fix is to remove unbounded recursion from code that processes untrusted or large inputs. Replace the call stack with an explicit stack on the heap:

// recursive: depth limited by the engine stack
fn sum(node: &Node) -> u64 { node.value + node.children.iter().map(sum).sum::<u64>() }

// iterative: depth limited only by heap memory
fn sum_iter(root: &Node) -> u64 {
    let mut total = 0;
    let mut stack = vec![root];
    while let Some(n) = stack.pop() {
        total += n.value;
        stack.extend(n.children.iter());
    }
    total
}

Recursive descent parsers can be kept recursive with an explicit depth limit that returns a clean error (“nesting deeper than 512 levels”) instead of overflowing; many JSON parsers do exactly this. Tail-recursive functions can benefit from the tail-call proposal where the compiler emits return_call, as discussed in tail calls and deep recursion.

Step 3 — shrink large frames

Frames with many locals or large inlined structures overflow at shallower depths. In optimised builds, aggressive inlining can merge many functions into one huge frame. If recursion depth is moderate but overflows anyway, check for large arrays declared on the stack — let buf = [0u8; 65536]; in Rust or char buf[65536]; in C — inside recursive functions, and move them to the heap (vec![0u8; 65536], malloc). Marking a recursive helper #[inline(never)] can also keep its frame small.

Fixes for native stack overflow Converting recursion to iteration removes the depth limit entirely. A depth limit keeps recursive code but fails cleanly on hostile input. Shrinking frames raises the reachable depth. Running in a worker or changing engine limits only moves the ceiling and is not portable. iteration with an explicit stack depth limited only by heap works in every engine some code restructuring best for untrusted input explicit depth limit keeps recursive code returns a clean error rejects very deep inputs parsers and visitors smaller frames move big locals to the heap raises the reachable depth still has a ceiling complementary fix

Step 4 — do not rely on engine stack sizes

Browsers do not expose a way to enlarge the native stack, and its size differs between engines, platforms, the main thread and workers. A module that works at depth 9,000 in desktop Chrome may fail at 4,000 in Safari on iOS. Node accepts --stack-size, but changing it is fragile and can crash the process rather than throw. Treat any input-dependent recursion depth as a bug, not a configuration problem. Test the deepest supported input in every target browser as part of the test suite.

Step 5 — return a useful error

When a depth limit or overflow does occur, make the failure informative. Catch the RangeError in the wrapper and translate it into a domain error (“document nested too deeply”), and since the overflow aborted the call mid-way, re-instantiate the module before reusing it, as described in recovering a module after a trap. Better still, enforce the documented limit inside the module so the error is a normal return value and the instance remains usable.

Languages with runtimes

Garbage-collected languages compiled to Wasm add their own twist. Go compiled with TinyGo or the standard toolchain manages goroutine stacks itself, and deep recursion may trigger the runtime’s stack growth logic before the engine’s limit, with different error messages. Kotlin/Wasm and other WasmGC languages use the engine’s native stack directly, so their recursion limits match the browser’s, similar to JavaScript. Interpreters compiled to Wasm — Python via Pyodide, Ruby, Lua — run the interpreted program’s recursion on their own data structures but recurse natively for some operations, so RecursionError in Python and a native RangeError from the interpreter are both possible; Pyodide documents its default recursion limit, which is lower than CPython’s for this reason. Whatever the language, the advice is the same: bound recursion on input-dependent data, and test with the deepest inputs you claim to support.

Measuring the safe depth

A quick experiment establishes the margin you have. Build a test that calls the recursive function with nesting depths doubling from 100 upward until it fails, and record the last successful depth per browser and per thread. Run it on the slowest phone you support, in a worker and on the main thread. The smallest value is your real limit; set the documented maximum input depth well below it — half is a sensible margin — and enforce that maximum in code. Rerun the experiment after compiler or optimisation-level changes, since inlining decisions change frame sizes and can lower the limit without any source change.

Mutual recursion between JavaScript and Wasm

Not every overflow is a single recursive function. A Wasm export that calls a JavaScript import, which calls back into another export, which calls the import again, builds a stack of alternating frames — and boundary frames are larger than ordinary Wasm frames, so such cycles overflow sooner than pure Wasm recursion. Event-handler code is a common source: a module that dispatches events to JavaScript callbacks, which synchronously trigger more module work, can recurse without any recursive function in either language. The stack trace shows interleaved Wasm and JavaScript frames. The fix is to break the cycle by queueing: have the callback record the follow-up work and return, then process the queue in a loop at the top level, or defer it with a microtask. That also makes the behaviour easier to reason about, since state changes happen in a predictable order.

Expected output

A JSON document nested 100,000 levels deep returns Error: nesting deeper than 512 levels instead of overflowing; the iterative tree walk handles a degenerate 1,000,000-node chain; and the cross-browser depth test passes in Chrome, Firefox and Safari.

Gotchas

  • Confusing the two stacks. RangeError is the engine stack; silent corruption is usually the shadow stack.
  • Testing only in desktop Chrome. Mobile and worker stacks can be much smaller.
  • Large arrays in recursive functions. They shrink the reachable depth dramatically. Move them to the heap.
  • Reusing the instance after an overflow. State may be inconsistent. Re-instantiate.
  • Relying on --stack-size in Node. It is fragile and not portable to browsers.

Performance note

The iterative tree walk ran within 3% of the recursive version on shallow inputs in Chrome, and handled inputs 1,000× deeper. A recursive parser with a 512-level depth limit had no measurable overhead from the depth counter.

Maximum recursion depth before overflow by environment Deepest successful call of the same recursive Wasm function in desktop Chrome on the main thread, desktop Chrome in a worker, Firefox, and Safari on iOS, showing how much the limit varies. maximum depth reached (thousands of frames) Chrome, main thread 11.2 k Chrome, worker 8.7 k Firefox 21 k Safari (iOS) 4.1 k

Frequently Asked Questions

Can I increase the browser’s stack size? No. Browsers do not expose it. Restructure the code instead.

Does running in a Web Worker help? Rarely — worker stacks are often the same size or smaller than the main thread’s.

Why does it only fail in release builds? Inlining can merge functions into larger frames, reducing reachable depth. Check frame sizes in optimised output.

Does the tail-call proposal solve recursion? Only for genuine tail calls that the compiler emits as return_call. Tree recursion is not tail recursion.

Is the shadow stack size related? It is independent. Increase it with link flags if address-taken locals overflow; see the stack-size guide.

Why do I see JavaScript frames in a Wasm stack overflow? Calls bounce between Wasm exports and JavaScript imports. Break the cycle by queueing follow-up work instead of calling back synchronously.

← Back to Troubleshooting Common Wasm Errors