Eli Bendersky recently published a detailed walkthrough of compiling Scheme to WebAssembly. It is worth reading not primarily as a Scheme article but as a diagnostic of where WebAssembly stands as a general compilation target. The choice of Scheme as the subject is not incidental. Scheme is small enough that one person can implement a complete compiler for a meaningful subset, but its semantics are demanding enough that it exposes every gap in the platform. If you want to know what a compilation target is actually capable of, pick a language that puts real pressure on it.
Most WebAssembly tutorials use toy languages: simple arithmetic expression evaluators, basic calculators, small if-then-else languages. These require a handful of Wasm instructions, a flat linear memory, and some function calls. They are good for learning the syntax. They are not good for understanding what Wasm can and cannot do as a backend for a real language runtime. Scheme forces the question.
Proper Tail Calls Are Not Optional
R7RS, the current Scheme standard, mandates proper tail recursion. This is a semantic requirement, not a performance hint. A compliant Scheme implementation must guarantee that a call in tail position does not grow the call stack, regardless of recursion depth. Code like this must run in constant stack space:
(define (loop n)
(if (= n 0)
'done
(loop (- n 1))))
For most of Wasm’s history, there was no instruction that expressed this. The workaround was trampolining: restructure tail calls so they return thunks, and have a driver loop invoke those thunks iteratively.
;; Trampoline-compatible version of a tail-recursive function
(define (loop/tramp n)
(if (= n 0)
'done
(lambda () (loop/tramp (- n 1)))))
(define (trampoline f)
(let run ([v f])
(if (procedure? v) (run (v)) v)))
The trampoline works, but it changes the compiled output substantially. Every tail call becomes a heap allocation. The transformation is not local; it propagates through every function that participates in a tail-call loop. The overhead is real and the code is harder to reason about.
The Wasm tail call proposal introduced return_call and return_call_indirect, which perform a call in tail position without allocating a new stack frame. This landed in Chrome 112 in April 2023, and Firefox and Safari followed later in 2023 and 2024. With return_call, the compiled output for the loop above is direct:
(func $loop (param $n i32) (result i32)
(if (i32.eqz (local.get $n))
(then (i32.const 1)) ;; 'done encoded as a tagged value
(else
(return_call $loop
(i32.sub (local.get $n) (i32.const 1))))))
No thunks. No allocations per iteration. The semantic requirement maps directly to a Wasm instruction.
This matters well beyond Scheme. Erlang’s process model depends on tail-recursive loops. OCaml and Haskell both benefit from tail call guarantees. Kotlin and Dart, which both target Wasm, have their own tail recursion idioms. The tail call proposal was motivated partly by these languages, and its shipping across all major browsers closes a gap that affected the entire class of higher-level language runtimes.
Closures Require Typed Heap Allocation
Scheme closures capture their lexical environment. A closure is a pair: a code reference and the set of variables visible at the point of closure creation. Representing this in Wasm’s flat linear memory is possible, but it requires a custom allocator, manual layout decisions for environment structs, a tagging scheme for dynamically typed values, and a garbage collector.
The Wasm GC proposal introduced managed heap types: struct for fixed-layout records, array for homogeneous sequences, and reference types that the host runtime can track and collect. These types live outside linear memory. The compiler declares their layout; the runtime allocates and reclaims them.
For Scheme closures, the representation becomes straightforward:
(type $env (struct
(field $x (mut anyref))
(field $y (mut anyref))))
(type $closure (struct
(field $fn (ref $func-type))
(field $env (ref $env))))
The compiler allocates a $closure struct for each closure value, pointing to the appropriate function and to an environment struct that holds the captured variables. The host GC collects both when they are no longer reachable. The compiler does not implement mark-and-sweep, does not manage a nursery, does not track reference counts.
Before the GC proposal, a Scheme compiler targeting Wasm had to implement all of that in linear memory. The Spritely Hoot project, which compiles Guile Scheme to Wasm, went through this in its early stages. It is doable. It is also a large body of code that duplicates work the host runtime already performs, and it cannot easily interoperate with the host heap for JavaScript object passing in browser contexts.
The GC proposal reached Phase 4 in late 2023 and shipped in Chrome 119 and Firefox 120. It is the proposal that most directly enables higher-level language runtimes on Wasm, because it provides the building block that nearly all of them need: automatic lifetime management for heap-allocated values.
Continuations Are the Hard Case
call-with-current-continuation, call/cc, is the mechanism in Scheme that captures the current continuation as a first-class value. You can store it, invoke it later, invoke it multiple times from different call sites. It is the general mechanism underlying exceptions, coroutines, generators, and nondeterministic search. It is also where most Scheme-to-Wasm efforts either stop or get complicated.
Wasm’s control flow is structured: blocks, loops, and if-else constructs with labeled branches. There is no way to capture or restore arbitrary call stack state from within a Wasm program. This is intentional; structured control flow enables fast validation and makes the sandbox guarantees tractable. But it rules out a direct translation of call/cc.
The standard approach is a CPS transformation, converting the entire program to continuation-passing style before emitting Wasm. In CPS, every function takes an extra argument representing its continuation, the computation that will consume the function’s result. Functions never return implicitly; they call their continuation. All control flow is explicit.
;; Direct style
(define (add x y) (+ x y))
;; CPS
(define (add-cps x y k)
(k (+ x y)))
;; call/cc in CPS: pass k as both argument and continuation
(define (call/cc-cps f k)
(f k k))
In a CPS-transformed program, every tail call is already in tail position, so return_call applies uniformly throughout the program. Continuations are closures, so the GC proposal handles their lifetime. The combination of CPS transformation, tail calls, and GC-managed structs is exactly what Spritely Hoot uses: CPS transform first, then emit Wasm using return_call for tail calls and GC structs for closure environments and continuation values.
The transformation is invasive. Every function in the program gains a continuation argument, and every call site passes one. The IR grows more complex. But the output is a Wasm program that compiles cleanly without any special continuation support from the host, which means it runs in every browser that supports the GC and tail call proposals.
Full restartable continuations, where a captured continuation can be invoked multiple times from arbitrary future call sites, require copying or restoring stack state. Many Scheme compilers restrict to escape-only or one-shot continuations, which cover exceptions and simple coroutines but not the full generality of call/cc. The Wasm stack-switching proposal, still in development, would provide native support for more general cases.
What This Says About Wasm as a Target
Wasm started as a compilation target for C and C++. These languages map cleanly to linear memory and structured control flow. They do not have garbage collection, continuations, or mandatory tail call semantics. A C-to-Wasm compiler does not need the GC proposal. A Rust-to-Wasm compiler does not need return_call. The original design of Wasm was adequate for the languages it was designed for.
The proposals that have shipped since 2022, GC, tail calls, exception handling, reference types, have been driven primarily by higher-level language communities: Kotlin, Dart, OCaml, Java, and Scheme. Each of these languages has runtime semantics that linear memory and structured control flow cannot accommodate cleanly. The proposals exist because real compilers hit real walls.
Scheme is a useful stress test because it is small enough to compile end-to-end with a modest implementation effort, and demanding enough to exercise every one of these features. A toy expression language will not require return_call. A toy calculator will not require GC structs. A language without closures will not require typed function references. Scheme requires all of them, and compiling it cleanly to Wasm requires all of the relevant proposals to be in place.
The broader point is that Wasm is now a credible target for languages with demanding runtime semantics, not just for languages that compile to flat memory. Hoot demonstrates this concretely: it passes a substantial portion of the R7RS test suite and runs nontrivial programs in both browser and server environments. That was not possible with the original Wasm spec.
Eli’s post is worth reading in full. He works through the lowering decisions in detail, including how closures are represented in GC types, how tail calls appear in the emitted bytecode, and where the seams are between the compiler and the Wasm runtime. The exercise of compiling a real language to a new target always surfaces things that tutorials avoid, and this one is no exception.