· 6 min read ·

The Determinism Dividend: Why WebAssembly Makes Time Travel Debugging Tractable

Source: lobsters

Time travel debugging has been a recurring dream in the tooling world for decades. The basic idea is straightforward: record a program’s execution, then let the developer scrub backwards and forwards through that recording as freely as they step through code in a normal debugger. In practice, building this for native code has always required heroic engineering effort.

The gabagool debug adapter brings time travel debugging to WebAssembly, and the interesting thing is not just that someone built it, but how much easier the target platform makes the whole problem.

Why Time Travel Is Hard on Native Code

The canonical tool for time travel debugging on Linux is Mozilla’s rr. Robert O’Callahan and his colleagues built rr to record the execution of a process with enough fidelity to replay it deterministically later. The technical approach involves intercepting all system calls, using hardware performance counters to capture precise instruction counts at context switches, and carefully recording all sources of non-determinism: signal delivery timing, thread scheduling, memory-mapped file contents, and the results of calls like gettimeofday.

On Windows, WinDbg’s Time Travel Debugging (TTD) takes a different approach, using kernel-level instrumentation to log every memory write and indirect branch target into a compact trace file. Microsoft describes the resulting .run files as containing enough information to reconstruct the exact register and memory state at any instruction in the trace.

Pernosco, built by some of the original rr team, layers a rich web-based UI on top of rr recordings and adds a server-side query engine that can answer questions like “show me every value this variable ever held” without requiring the developer to already know where to look.

All of these systems share a common challenge: native code is full of non-determinism that must be actively suppressed or recorded. Threads can be scheduled in any order. Hardware timers fire unpredictably. ASLR randomizes addresses on each run. System calls return results that depend on external state. The recording infrastructure has to intercept all of this, which is why rr only works on Linux with specific hardware, and why TTD requires elevated privileges and produces large trace files.

WebAssembly’s Execution Model Changes the Problem

WebAssembly is specified to be deterministic. Given the same sequence of inputs, a Wasm module will produce the same outputs and reach the same internal states every time. This is not accidental; it is a deliberate design goal that makes Wasm portable across different CPU architectures and operating systems without behavioral differences.

The sources of non-determinism in Wasm are explicitly enumerated and narrowly scoped. The spec identifies only a few: the results of memory.grow and table.grow operations (which can fail if the host declines to allocate), the behavior of unreachable and undefined behavior in proposals like threads, and some NaN propagation behavior in SIMD operations. Host function imports are technically non-deterministic since they can call into the outside world, but they are a clean boundary, not woven through the fabric of execution the way syscalls are in native code.

This means that for a Wasm module whose imports are recorded, you get deterministic replay essentially for free. You do not need hardware performance counters. You do not need to intercept the kernel scheduler. The execution engine itself, if it is a pure interpreter or a sufficiently disciplined JIT, will produce identical results given identical inputs.

The Debug Adapter Protocol as the Integration Layer

Gabagool’s debug adapter implements Microsoft’s Debug Adapter Protocol (DAP), which is the same protocol that powers debugger integrations in VS Code and any other DAP-compatible editor. DAP is a JSON-RPC based protocol where the editor (the “client”) sends requests like setBreakpoints, stackTrace, and next to the debug adapter (the “server”), which in turn controls the actual debugger and sends back events like stopped and output.

The protocol was designed to decouple the editor from the debugger implementation. Before DAP, every editor had to implement custom integrations for every debugger, resulting in an N×M compatibility matrix. DAP collapses this to N+M: editors implement the client side once, and debuggers implement the server side once.

For time travel debugging, the relevant DAP capabilities are stepBack and reverseContinue. These are optional features in the protocol, which means editors only surface them when the adapter reports support. When they are present, VS Code adds backwards navigation buttons to the debug toolbar automatically. The editor does not need any special awareness of time travel; it just exposes whatever the adapter claims to support.

This is a meaningful design win. It means gabagool’s users do not need a custom frontend, and the project does not need to build one. Any editor that speaks DAP gets backwards stepping as soon as the adapter is running.

How Record and Replay Works in an Interpreter

A Wasm interpreter has a structural advantage for time travel that a JIT compiler does not. In an interpreter, every instruction passes through a central dispatch loop, which means you have a natural place to insert recording and checkpointing logic without patching generated machine code.

The simplest viable approach is periodic checkpointing: snapshot the entire Wasm execution state (the value stack, the call stack, linear memory, globals, and table contents) at regular intervals, then record every host call made between checkpoints along with its result. To step backwards past a checkpoint boundary, you restore the snapshot and fast-forward through the recorded host calls to reach the precise instruction you want.

This is more expensive than rr’s hardware-assisted approach in terms of replay cost, but it is dramatically simpler to implement and does not require any special hardware or OS support. The cost is bounded by how far apart checkpoints are spaced and how much of Wasm linear memory needs to be snapshotted, which in many applications is modest.

A more sophisticated implementation can reduce snapshot sizes by tracking which memory pages have been written since the last checkpoint (a technique borrowed from copy-on-write virtual memory semantics) and only recording dirty pages. This makes full checkpoints cheap enough to take frequently, which in turn reduces replay latency when stepping backwards.

Comparing to Existing Wasm Debugging Approaches

The current state of WebAssembly debugging outside tools like gabagool is functional but limited. Chrome DevTools has supported DWARF-based Wasm debugging since 2020, via the C/C++ DevTools Support extension and, more recently, built-in support in V8. This gives you source-level stepping, variable inspection, and call stacks mapped back to original C, C++, or Rust source. What it does not give you is any ability to go backwards.

Wasmtime, the reference Wasm runtime from the Bytecode Alliance, has a debugger integration based on GDB and LLDB over a custom remote protocol. It supports DWARF information and gives you the usual forward-only debugging experience.

For server-side or embedded Wasm use cases, where modules run outside the browser in runtimes like Wasmtime, WASMer, or wasm-micro-runtime, the debugging story has historically been thin. You either embed printf-style logging in the module itself or rely on the host runtime’s limited inspection capabilities.

Gabagool positions itself in this gap: a standalone runtime with time travel built into the execution model from the start, exposing it through a protocol that already works with the tools developers use daily.

The Broader Implication

Time travel debugging tends to change how you approach certain classes of bugs. Heisenbugs that require precise reproduction steps become more tractable when you can record one occurrence and replay it indefinitely. Bugs that only manifest after long sequences of operations become reachable by running the program until failure, then stepping backwards from the crash to find the root cause, rather than trying to narrow down the reproducer first.

For WebAssembly specifically, this matters in contexts like smart contract execution (where determinism is already required by the platform), plugin sandboxing (where you want to understand exactly what a third-party module did), and edge computing (where attaching a conventional debugger to a remote runtime is impractical). In all of these cases, the ability to record an execution trace and replay it locally for inspection has real operational value beyond developer convenience.

The gabagool project is small and relatively early, but the technical foundation it is building on is sound. WebAssembly did not intend to be the perfect host for time travel debugging; it just ended up that way because determinism was a design requirement for portability. That is an example of good constraints compounding in unexpected directions, and it is worth paying attention to.

Was this interesting?