필사 모드: Simulating Systems in the Browser — What WASM Makes Possible and What Still Blocks It
English- Introduction — Two TCP Stacks, One x86 CPU, and One Debian, All in a Single Tab
- Why It's Practical Now — The Pieces Wasm 3.0 Filled In
- Constraint 1 — There Are No Sockets
- Constraint 2 — Threading Hangs on Two Lines of HTTP Headers
- Constraint 3 — Binary Size and Startup Time
- An Unexpected Payoff — Determinism
- Where a Good Teaching Tool Ends and a Toy Begins
- Closing — A Runtime Environment Shipped With a Single Link
- References
Introduction — Two TCP Stacks, One x86 CPU, and One Debian, All in a Single Tab
On July 28, 2026, Simulating TCP loss and congestion in browser using Go/WASM hit Hacker News. Open the link and you'll see CUBIC's and BBRv3's congestion windows plotted side by side — but what draws that graph isn't a data file. It's two gVisor TCP stacks actually running inside the browser. That's what the repository says, and checking the .wasm file the browser actually downloads confirms it: 8,664,912 bytes, about 2.35MB gzipped.
This isn't an isolated case. v86 translates x86 machine code into WebAssembly modules on the fly to boot Windows 98, ReactOS, and 9front, and it has passed 23,000 stars. WebVM runs unmodified Debian on top of an engine called CheerpX — complete with an x86-to-WASM JIT, a block-based virtual filesystem, and a Linux system-call emulator. On July 15, 2026, a demo that built Firefox itself entirely into WebAssembly picked up 273 points.
For a while, things like this felt like demos in the "sure, it works, but why bother" category. Not anymore. This post lays out why this pattern has become practical now, which constraints still dominate the design space, and where the line falls between a genuinely useful teaching tool and a toy.
Why It's Practical Now — The Pieces Wasm 3.0 Filled In
WebAssembly 3.0 was released by the W3C Community Group on September 17, 2025, formalizing nine features at once. Filtered down to what matters for system simulation, here's what stands out.
Exception handling went native. Before this, you either round-tripped C++ or Rust unwinding through JavaScript, or faked it with a transformation like Asyncify — both expensive. This is exactly the path that code emulating an OS hits when it handles traps and interrupts.
Memory64 made 64-bit addressing possible. That means the 4GiB address-space ceiling of wasm32 is gone, which is a direct relief for any emulator that needs to allocate a large guest memory. But it isn't free — 64-bit indexing costs more in bounds checking and runs slower than wasm32. If your workload fits inside 4GiB, wasm32 is still the better choice.
WasmGC lets managed languages (Java, Kotlin, Dart, and others) use the host VM's garbage collector instead of bundling their own. It's a major path to smaller binaries, and it's supported by all major browsers including Safari. Go and Rust don't take this path, though — Go runs its own GC on top of linear memory, and Rust has no GC at all.
On top of this come 128-bit SIMD, tail calls, multiple memories, typed function references, and branch hinting. Tail calls help interpreter loops; multiple memories can be used to separate guest memory from host data structures.
Put simply, WASM circa 2020 was "a box for running pure computation fast," and today's WASM is "a runtime that can handle exceptions, GC, and large memory." That difference is what made the idea of running system software inside a browser realistic. The story of WASM outside the browser is covered in WebAssembly Beyond the Browser; the dev tools that actually run real engines in the browser today are covered in Real Engines in Your Browser: A Tour of WebAssembly Dev Tools.
Constraint 1 — There Are No Sockets
This is the most fundamental constraint. The browser sandbox gives you no raw sockets. No arbitrary TCP connections, no UDP, and certainly no raw IP. What you get is fetch, WebSocket, WebRTC data channels, and WebTransport — all higher-level protocols.
This one fact splits browser system simulators into exactly two branches.
The first branch simulates the entire network. This is ccsim's approach — the sender, the receiver, and the link between them all live inside the process, so nothing ever needs to leave. The link model throttles bandwidth with a token bucket, injects delay and jitter, generates loss from a seeded random source, manages queues with taildrop, RED, CoDel, or FQ-CoDel, and even does ECN CE marking. It's not just that a real network isn't needed — it's actually better without one, because that's what makes it reproducible.
The second branch tunnels the lower layer. v86 emulates an NE2000 PCI network card, but the Ethernet frames the guest sends out ultimately have to leave through a WebSocket relay to reach the real network. WebVM is even more upfront about it — it wires networking through Tailscale and tells you to use an exit node to reach the public internet. And its README carries this note.
Some low-level networking operations (in particular the ICMP protocol used by
ping) aren't currently available in this environment. Usecurlorwgetto check connectivity instead.
There's a full Linux inside the browser, and ping doesn't work. That one line shows the exact shape of the constraint — ICMP sits below the socket layer, and unless whatever's on the other end of the tunnel builds it for you, it simply doesn't exist.
The design lesson here is this: if a simulator's purpose is to teach network behavior, it should pick the first branch. Tunneling creates a dependency on a relay server, and that relay contaminates the exact characteristics — latency, loss, queueing — you were trying to simulate in the first place. Conversely, if the goal is to show real software actually running, tunneling is the only road there.
Constraint 2 — Threading Hangs on Two Lines of HTTP Headers
To get real parallel execution in WebAssembly, multiple workers need to share the same linear memory, and that requires SharedArrayBuffer. But ever since the Spectre-class vulnerabilities, SharedArrayBuffer has only been available on cross-origin isolated documents. The condition comes down to two response headers.
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
You can relax this with credentialless, but the substance is the same. And the blast radius of these two lines is bigger than it looks.
- Every cross-origin subresource the page loads has to carry a CORP or CORS header. An image CDN, a font, an analytics script, an embedded YouTube video — if even one is missing the header, it gets blocked from loading.
- COOP severs the
window.openerrelationship. Anything relying on an OAuth popup flow or communication with a parent window breaks. - Above all, you need control over the server configuration. If you can't add response headers on a static host like GitHub Pages, this whole path is closed. There are known hacks that route around this with a service worker, but they don't work on the first visit and debugging them is nasty.
And if you're using Go, this whole discussion becomes moot. The GOOS=js GOARCH=wasm target is single-threaded. Goroutines are multiplexed on a single JavaScript event loop, so no matter how many you spin up, you're still using exactly one core. That's why ccsim runs in a single worker. In ccsim's case, though, this wasn't a cost — it was actually a design requirement, since it forces all netstack TCP processing to run inline on the event-loop goroutine for the sake of determinism.
Confirming that the headers are actually attached takes one line. And your local dev server almost certainly doesn't send these headers by default, so you have to attach them yourself.
# check whether the isolation headers are present on a deployed page
curl -sI https://example.com/app/ | grep -i 'cross-origin-'
# attach them locally (using only the Python standard library)
python3 - <<'PY'
import http.server, functools
class H(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
super().end_headers()
http.server.ThreadingHTTPServer(('', 8000), H).serve_forever()
PY
Checking whether crossOriginIsolated is true in the browser console gives you the final confirmation. If it's false, the SharedArrayBuffer constructor doesn't even exist.
There's a settled practical pattern for keeping the UI from freezing on a single thread: run the simulation in a worker, exchange only messages with the main thread, and yield periodically even inside the worker. ccsim's worker glue cuts work into batches of 250ms of simulation time, yields via MessageChannel, and then picks up the next batch. That's how the chart fills in progressively instead of making you wait for the whole run to finish.
Constraint 3 — Binary Size and Startup Time
This is where language choice actually diverges. Separating what's directly verified from what's widely reported, here's how it breaks down.
| Toolchain | Representative output size | Threading | Character |
|---|---|---|---|
Go (GOOS=js GOARCH=wasm) | ccsim measured 8.66MB, 2.35MB gzipped. Even minimal examples usually run in the MB range | Fixed single-threaded | Links the runtime and GC in whole; the full standard library comes along |
| TinyGo | Community reports put it 10-20x smaller than standard Go | Limited | Links only what's used. Constraints on reflection and parts of the standard library |
| Rust (wasm-bindgen) | Small modules run in the tens of KB | Possible with SAB | Essentially no runtime; wasm-opt shaves off another 10-30% |
| C/C++ (Emscripten) | Proportional to code volume | Possible with SAB | A path for porting existing native codebases; much of v86 falls in this camp |
Checking this yourself is straightforward too. Build your own project to WASM and compare the two numbers, and it becomes immediately clear which cost actually matters for you.
# Go: the standard toolchain
GOOS=js GOARCH=wasm go build -o main.wasm ./cmd/sim
ls -l main.wasm
gzip -9 -c main.wasm | wc -c # this is what actually gets transferred
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" .
# which symbols are eating the size
go tool nm -size -sort size main.wasm | head -30
# build the same code with TinyGo to compare (check for constraints first)
tinygo build -o tiny.wasm -target wasm -opt=z ./cmd/sim
# shave off more with Binaryen if you have it
wasm-opt -Oz main.wasm -o main.opt.wasm && ls -l main.opt.wasm
Before concluding "Go doesn't belong on WASM" from that 8.66MB figure, you have to look at what's actually inside that 8.66MB. In ccsim's case, it's two complete copies of gVisor's TCP/IP stack, loss detection including RACK/TLP, an SACK scoreboard, a BBRv3 state machine, a link model, and four kinds of queue discipline. Whether rewriting that in Rust or living with 8.66MB (2.35MB over the wire) is the bigger problem depends entirely on your situation. The fact that gVisor is written in Go already decided the language choice, and that's generally the right criterion to use.
Go's real cost is more often startup time than size. Before the first line executes, you have to load the wasm_exec.js glue (about 17KB), compile and instantiate a module over 8MB, initialize the Go runtime, and get the GC ready. Rust skips essentially the last two of these steps entirely.
Execution speed isn't negligible either. The acceptance-criteria table in the ccsim repository is honest about it — the same 30-second simulation takes 1.4 seconds on native arm64 and 7.6 seconds under WASM on Node v26. That's about 5.4x. There's a trace of how this kind of gap shows up in the UI, too — a warning sits at the bottom of the screen.
Defaults shown are pre-computed — moving a slider runs the simulator for real on this device, which may take a while.
The makers' own blog says as much: "we found it takes over a minute on older devices, so we pre-computed the default scenario." This is a decision nearly every browser-simulator builder eventually runs into: paint the first screen instantly from a pre-computed result, and only run the real thing once interaction starts.
Memory is worth a note too. wasm32's address space is 4GiB, but the linear memory you can actually hold reliably in a single tab is far smaller than that, and it varies by browser and platform. Mobile Safari is especially stingy. Emulators that need a large guest RAM hit this wall first, which is no accident — it's exactly why most of v86's demos run old systems sized around 128MB.
An Unexpected Payoff — Determinism
This is the single most valuable side effect of building a browser simulator. A WASM target has no threads, time comes from the host, and randomness comes from the host too. That makes it an environment where determinism comes easily. And a deterministic simulator is, by itself, a regression test.
ccsim pushed this all the way, so its approach is worth studying directly. The repository names four components of its determinism.
- A single virtual clock. netstack timers, link events, application writes, sampling ticks, and scenario injection all land on one min-heap, with ties broken FIFO. There is exactly one source of time.
- Inline dispatch. gVisor is patched so that all netstack TCP processing runs synchronously on the event-loop goroutine. No goroutine ever races the clock.
- Named PCG substreams. Link loss (forward/reverse), RED decisions, arrival times, each stack's randomness source, and per-flow BBR probe jitter each get their own separate substream. A change in call count in one place doesn't perturb the random sequence anywhere else.
- Blocking FMA fusion. Every floating-point multiply-add on the simulation path gets an explicit
float64conversion so the compiler can't fuse it into an FMA. This is so arm64, amd64, and wasm all produce the exact same bits.
That fourth point is especially telling. Wanting cross-platform determinism means controlling even whether floating-point operations get fused, and most projects never go that far. What you get in return is unambiguous — the sample streams from the native build and the WASM build are byte-for-byte identical, and that's enforced by tests. It's a guarantee that the graph you see in the browser and the result that ran in CI are the same object.
Why this matters as a teaching tool is that the question "but it looks different in my browser" can't even arise in the first place.
Where a Good Teaching Tool Ends and a Toy Begins
For a browser simulator to actually teach something, a few things have to line up. Setting them next to the ways this fails makes the line visible.
Is it running a real implementation, or is it an animation? ccsim runs gVisor's actual TCP code. So things like what happens when the SACK scoreboard overflows past 100 ranges, RACK reordering judgments, ECN echoes — these aren't approximations, they're the real thing. On the other hand, something that interpolates a pre-drawn curve when you move a slider only gives you the impression that "congestion control looks like this," and it won't tell you the truth about an unexpected combination. Telling the truth about unexpected combinations is the entire reason a simulator exists.
Is it validated? ccsim fits the CUBIC growth curve against RFC 9438's cubic function and records the coefficient of determination, tests the RED marking curve with a chi-squared test, and runs golden-stream regression tests. An unvalidated simulator plants false intuitions with total confidence. That's worse than learning nothing at all.
Does it disclose its boundaries? A simulator that tells you there's only one flow, one bottleneck, no middleboxes, and no CPU or interrupt handling is a different thing from one that doesn't tell you any of that.
Does it start fast? Nobody sticks around for a teaching tool that makes you download 8MB and wait 30 seconds before the first picture appears. Choosing to draw instantly from a pre-computed default decides roughly half of the educational value.
On the flip side, the conditions under which this approach ends up as a toy are just as clear. Simulating a topic where real hardware timing is the whole point — cache hierarchies, NUMA, interrupt latency — in a browser teaches you less than it misleads you. The same goes for topics where scale is the whole point: the interaction of thousands of flows, tail latency across a large cluster. And for any topic that's only meaningful when it interacts with a real network, the relay contaminates the very thing you're trying to simulate, as we saw earlier.
Boiled down to one sentence: a browser simulator is strong at teaching how rules interact inside a closed system, and weak at teaching how messy the real world actually is.
Closing — A Runtime Environment Shipped With a Single Link
To summarize.
- Wasm 3.0's exception handling, Memory64, and WasmGC turned WASM from a "computation box" into a runtime environment that can run system software.
- The absence of raw sockets splits designs into two branches: simulate the entire network (ccsim), or tunnel through a relay (v86, WebVM). If the goal is teaching, it's the former.
- Real parallel execution rests on
SharedArrayBuffer, which rests on COOP/COEP headers, which rests on control over the server. If you're targeting Go, this whole discussion doesn't even apply — it's single-threaded. - Go is big (8.66MB measured, 2.35MB gzipped) and starts slowly, but if the code you're porting is already Go, that's the deciding factor. Budget for roughly 5x slower execution than native.
- The WASM target's constraints — no threads, time and randomness supplied by the host — make determinism easy to get. A deterministic simulator is, by itself, a regression test.
And there's one reason that makes all these constraints worth accepting. With no install, no account, and no cluster, you can ship a runnable system behind a single link. As a teaching tool, not much beats that quality.
References
- Simulating TCP loss and congestion in browser using Go/WASM — Hacker News (item 49088098)
- apoxy-dev/ccsim — determinism design, WASM parity, performance acceptance criteria
- BBRv3 for gVisor's netstack — the maker's engineering blog
- copy/v86 — an x86 PC emulator with an x86-to-wasm JIT
- leaningtech/webvm — a CheerpX-based browser Linux VM
- Mini.WebVM — building a browser Linux box from a Dockerfile
- WebAssembly 3.0 release announcement (2025-09-17)
- MDN — SharedArrayBuffer and cross-origin isolation requirements
- Go Wiki — WebAssembly target guide
- TinyGo — binary size optimization guide
- WebAssembly Beyond the Browser (related post)
- Real Engines in Your Browser: A Tour of WebAssembly Dev Tools (related post)
현재 단락 (1/89)
On July 28, 2026, [Simulating TCP loss and congestion in browser using Go/WASM](https://news.ycombin...