Split View: 브라우저에서 시스템을 시뮬레이션하기 — WASM이 가능하게 한 것과 여전히 막는 것
브라우저에서 시스템을 시뮬레이션하기 — WASM이 가능하게 한 것과 여전히 막는 것
- 들어가며 — 한 탭 안에 TCP 스택 두 개, x86 CPU 하나, 데비안 한 대
- 왜 지금 실용적인가 — Wasm 3.0이 채운 조각
- 제약 1 — 소켓이 없다
- 제약 2 — 스레드는 HTTP 헤더 두 줄에 달려 있다
- 제약 3 — 바이너리 크기와 시작 시간
- 뜻밖의 이득 — 결정성
- 좋은 교보재와 장난감을 가르는 선
- 마치며 — 링크 하나로 배포되는 실행 환경
- 참고 자료
들어가며 — 한 탭 안에 TCP 스택 두 개, x86 CPU 하나, 데비안 한 대
2026년 7월 28일 해커뉴스에 Simulating TCP loss and congestion in browser using Go/WASM이 올라왔습니다. 링크를 열면 CUBIC과 BBRv3의 혼잡 윈도가 나란히 그려지는데, 그 그래프를 만드는 것이 데이터 파일이 아니라 브라우저 안에서 실제로 도는 두 개의 gVisor TCP 스택입니다. 저장소의 설명이 그렇게 돼 있고, 브라우저가 받아 가는 .wasm 파일을 직접 확인해 보니 8,664,912바이트, gzip으로 약 2.35MB였습니다.
이건 고립된 사례가 아닙니다. v86은 x86 머신 코드를 실행 중에 WebAssembly 모듈로 번역해 윈도우 98과 ReactOS와 9front를 부팅시키고, 별 23,000개를 넘겼습니다. WebVM은 CheerpX라는 엔진 위에서 수정하지 않은 데비안을 띄웁니다 — x86-to-WASM JIT, 블록 기반 가상 파일 시스템, 리눅스 시스템 콜 에뮬레이터를 갖춘 물건입니다. 2026년 7월 15일에는 Firefox를 통째로 WebAssembly로 빌드한 데모가 273포인트를 받았습니다.
한동안 이런 것들은 "되긴 되는데 왜 하는지 모르겠는" 시연에 가까웠습니다. 지금은 다릅니다. 이 글은 이 패턴이 왜 지금 실용적이 됐는지, 어떤 제약이 여전히 설계를 지배하는지, 그리고 훌륭한 교보재와 장난감을 가르는 선이 어디인지를 정리합니다.
왜 지금 실용적인가 — Wasm 3.0이 채운 조각
WebAssembly 3.0이 2025년 9월 17일에 W3C 커뮤니티 그룹에서 릴리스됐고, 아홉 개의 기능이 한꺼번에 정식화됐습니다. 시스템 시뮬레이션 관점에서 의미 있는 것만 추리면 이렇습니다.
예외 처리가 네이티브가 됐습니다. 이전에는 C++이나 Rust의 언와인딩을 자바스크립트로 왕복시키거나 Asyncify 같은 변환으로 흉내 내야 했고, 둘 다 비쌌습니다. OS를 에뮬레이션하는 코드에서 트랩과 인터럽트를 다루는 경로가 여기 걸립니다.
Memory64로 64비트 주소 지정이 가능해졌습니다. wasm32의 4GiB 주소 공간 천장이 사라진다는 뜻인데, 큰 게스트 메모리를 잡아야 하는 에뮬레이터에게는 직접적인 해방입니다. 다만 공짜가 아닙니다 — 64비트 인덱싱은 경계 검사 비용이 늘어나 wasm32보다 느립니다. 4GiB 안에 들어가는 워크로드라면 wasm32가 여전히 낫습니다.
WasmGC는 관리 언어(Java, Kotlin, Dart 등)가 자체 GC를 번들하지 않고 호스트 VM의 GC를 쓰게 해 줍니다. 바이너리 크기가 크게 줄어드는 경로이고, 사파리를 포함한 주요 브라우저가 지원합니다. 다만 Go와 Rust는 이 경로를 쓰지 않습니다 — Go는 자체 GC를 선형 메모리 위에서 돌리고, Rust는 GC가 없습니다.
여기에 128비트 SIMD, 테일 콜, 다중 메모리, 타입 지정 함수 참조, 분기 힌트가 더해집니다. 테일 콜은 인터프리터 루프를, 다중 메모리는 게스트 메모리와 호스트 자료 구조를 분리하는 데 쓸 수 있습니다.
정리하면, 2020년경의 WASM은 "순수 계산을 빠르게 돌리는 상자"였고 지금의 WASM은 "예외와 GC와 큰 메모리를 다룰 수 있는 실행 환경"입니다. 그 차이가 브라우저 안에서 시스템 소프트웨어를 돌린다는 발상을 현실적으로 만들었습니다. 브라우저 밖의 WASM 이야기는 브라우저 밖의 WebAssembly 편에, 실제로 브라우저에서 도는 개발 도구들은 브라우저에서 진짜 엔진이 돈다 편에 정리해 두었습니다.
제약 1 — 소켓이 없다
가장 근본적인 제약입니다. 브라우저 샌드박스는 원시 소켓을 주지 않습니다. 임의의 TCP 연결도, UDP도, 하물며 raw IP도 없습니다. 쓸 수 있는 것은 fetch, WebSocket, WebRTC 데이터 채널, WebTransport뿐이고 모두 상위 프로토콜입니다.
이 하나가 브라우저 시스템 시뮬레이터를 딱 두 갈래로 나눕니다.
첫째 갈래는 네트워크를 통째로 시뮬레이션하는 것입니다. ccsim이 이쪽입니다 — 송신자와 수신자와 그 사이 링크가 전부 프로세스 안에 있으므로 애초에 나갈 필요가 없습니다. 링크 모델이 토큰 버킷으로 대역폭을 제한하고, 지연과 지터를 넣고, 시드된 난수로 손실을 만들고, taildrop·RED·CoDel·FQ-CoDel 중 하나로 큐를 관리하고, ECN CE 마킹까지 합니다. 실제 네트워크가 필요 없는 정도가 아니라 없는 편이 낫습니다 — 재현 가능해지기 때문입니다.
둘째 갈래는 아래층을 터널링하는 것입니다. v86에는 NE2000 PCI 네트워크 카드가 에뮬레이션돼 있지만, 게스트가 내보낸 이더넷 프레임은 결국 WebSocket 릴레이를 타고 실제 네트워크로 나가야 합니다. WebVM은 더 노골적입니다 — 네트워킹을 Tailscale로 붙이고, 공용 인터넷에 나가려면 exit node를 쓰라고 안내합니다. 그리고 README에 이런 주석이 붙어 있습니다.
저수준 네트워킹 작업 일부(특히
ping이 쓰는 ICMP)는 현재 이 환경에서 사용할 수 없습니다. 연결 확인에는curl이나wget을 쓰세요.
브라우저 안에 완전한 리눅스가 있는데 ping이 안 됩니다. 이 한 줄이 제약의 정확한 모양을 보여 줍니다 — ICMP는 소켓 계층 아래에 있고, 터널 반대편이 그걸 대신 만들어 주지 않으면 존재하지 않습니다.
설계 관점의 교훈은 이렇습니다. 네트워크 동작을 가르치려는 시뮬레이터라면 첫째 갈래를 골라야 합니다. 터널링은 릴레이 서버라는 의존성을 만들고, 그 릴레이가 시뮬레이션하려던 바로 그 특성(지연, 손실, 큐)을 오염시킵니다. 반대로 실제 소프트웨어를 실행해 보여 주는 것이 목적이라면 터널링 외에 길이 없습니다.
제약 2 — 스레드는 HTTP 헤더 두 줄에 달려 있다
WebAssembly에서 진짜 병렬 실행을 하려면 여러 워커가 같은 선형 메모리를 공유해야 하고, 그러려면 SharedArrayBuffer가 필요합니다. 그런데 Spectre 계열 취약점 이후 SharedArrayBuffer는 교차 출처 격리된 문서에서만 쓸 수 있습니다. 조건은 응답 헤더 두 줄입니다.
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
credentialless로 완화할 수 있지만 본질은 같습니다. 그리고 이 두 줄의 파급이 생각보다 큽니다.
- 페이지가 불러오는 모든 교차 출처 하위 리소스가 CORP 또는 CORS 헤더를 갖고 있어야 합니다. 이미지 CDN, 폰트, 애널리틱스 스크립트, 임베드된 유튜브 — 하나라도 헤더가 없으면 로드가 막힙니다.
- COOP는
window.opener관계를 끊습니다. OAuth 팝업 흐름이나 부모 창과의 통신에 의존하는 코드가 깨집니다. - 무엇보다 서버 설정을 통제할 수 있어야 합니다. GitHub Pages 같은 정적 호스팅에 응답 헤더를 추가할 수 없다면 이 경로 자체가 닫힙니다. 서비스 워커로 우회하는 해킹이 알려져 있지만, 첫 방문에는 동작하지 않고 디버깅이 고약합니다.
그리고 Go를 쓴다면 이 논의가 아예 무의미해집니다. GOOS=js GOARCH=wasm 타깃은 단일 스레드입니다. 고루틴은 하나의 자바스크립트 이벤트 루프 위에 다중화되므로, 고루틴을 아무리 많이 띄워도 코어 하나만 씁니다. ccsim이 워커 한 개에서 도는 것도 그래서입니다. 다만 ccsim의 경우 이건 손해가 아니라 오히려 설계 요구사항이었는데, 결정성을 위해 모든 netstack TCP 처리를 이벤트 루프 고루틴에서 인라인으로 강제하고 있기 때문입니다.
헤더가 실제로 붙었는지 확인하는 것은 한 줄입니다. 그리고 로컬 개발 서버는 기본값이 이 헤더를 안 주므로 직접 붙여야 합니다.
# 배포된 페이지에 격리 헤더가 붙어 있는지
curl -sI https://example.com/app/ | grep -i 'cross-origin-'
# 로컬에서 붙여 보기 (파이썬 표준 라이브러리만으로)
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
브라우저 콘솔에서 crossOriginIsolated가 true인지 보면 최종 확인이 끝납니다. false라면 SharedArrayBuffer 생성자 자체가 없습니다.
단일 스레드에서 UI를 얼어붙지 않게 하는 실무 패턴은 정해져 있습니다. 시뮬레이션은 워커에서 돌리고, 메인 스레드와는 메시지로만 주고받고, 워커 안에서도 일정 단위마다 양보합니다. ccsim의 워커 글루는 시뮬레이션 시간 250ms 단위로 배치를 끊고 MessageChannel로 양보한 뒤 다음 배치를 이어 갑니다. 그래서 실행이 끝날 때까지 기다릴 필요 없이 차트가 점진적으로 채워집니다.
제약 3 — 바이너리 크기와 시작 시간
여기가 언어 선택이 실제로 갈리는 지점입니다. 직접 확인한 값과 널리 보고된 값을 구분해 정리하면 이렇습니다.
| 툴체인 | 대표적 산출물 크기 | 스레드 | 성격 |
|---|---|---|---|
Go (GOOS=js GOARCH=wasm) | ccsim 실측 8.66MB, gzip 2.35MB. 최소 예제도 보통 MB 단위 | 단일 스레드 고정 | 런타임과 GC를 통째로 링크. 표준 라이브러리 전체가 그대로 쓰임 |
| TinyGo | 커뮤니티 보고 기준 표준 Go 대비 10~20배 작음 | 제한적 | 쓰는 것만 링크. 리플렉션과 일부 표준 라이브러리에 제약 |
| Rust (wasm-bindgen) | 작은 모듈은 수십 KB 수준 | SAB 있으면 가능 | 런타임이 사실상 없음. wasm-opt로 추가로 10~30% 감소 |
| C/C++ (Emscripten) | 코드량에 비례 | SAB 있으면 가능 | 기존 네이티브 코드베이스 이식 경로. v86의 상당 부분이 이 계열 |
직접 확인하는 방법도 간단합니다. 자기 프로젝트를 WASM으로 빌드해 두 값을 비교해 보면 어느 쪽 비용이 문제인지 바로 나옵니다.
# Go: 표준 툴체인
GOOS=js GOARCH=wasm go build -o main.wasm ./cmd/sim
ls -l main.wasm
gzip -9 -c main.wasm | wc -c # 실제 전송량은 이쪽이다
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" .
# 어떤 심볼이 크기를 먹고 있는지
go tool nm -size -sort size main.wasm | head -30
# TinyGo로 같은 코드를 빌드해 비교 (제약이 있는지 먼저 확인할 것)
tinygo build -o tiny.wasm -target wasm -opt=z ./cmd/sim
# Binaryen이 있다면 한 번 더 줄인다
wasm-opt -Oz main.wasm -o main.opt.wasm && ls -l main.opt.wasm
Go의 8.66MB를 보고 "Go는 WASM에 부적합"이라고 결론 내리기 전에, 그 8.66MB에 무엇이 들어 있는지 봐야 합니다. ccsim의 경우 gVisor의 전체 TCP/IP 스택 두 벌, RACK/TLP를 포함한 손실 감지, SACK 스코어보드, BBRv3 상태 기계, 링크 모델과 네 종류의 큐 규율이 들어 있습니다. 이걸 Rust로 다시 쓰는 비용과 8.66MB(전송은 2.35MB) 중 무엇이 더 큰 문제인지는 상황에 따라 다릅니다. gVisor가 Go로 쓰여 있다는 사실이 언어 선택을 이미 결정한 것이고, 그것이 대체로 옳은 판단 기준입니다.
Go의 진짜 비용은 크기보다 시작 시간일 때가 많습니다. wasm_exec.js 글루(약 17KB)를 로드하고, 8MB 넘는 모듈을 컴파일·인스턴스화하고, Go 런타임이 초기화되고, GC가 준비돼야 첫 줄이 실행됩니다. Rust는 이 중 마지막 두 단계가 사실상 없습니다.
실행 속도도 무시할 수 없습니다. ccsim 저장소의 수용 기준 표가 정직합니다 — 같은 30초 시뮬레이션이 네이티브 arm64에서 1.4초, Node v26의 WASM에서 7.6초입니다. 약 5.4배입니다. 이 정도 격차가 UI에 어떻게 나타나는지도 코드에 흔적이 남아 있는데, 화면 하단에 이런 경고가 붙어 있습니다.
기본값은 미리 계산된 것입니다 — 슬라이더를 움직이면 이 기기에서 시뮬레이터가 실제로 돌고, 시간이 걸릴 수 있습니다.
만든 쪽 블로그에도 "오래된 기기에서는 1분이 넘게 걸리는 것을 발견하고 기본 시나리오를 미리 계산해 두었다"고 적혀 있습니다. 브라우저 시뮬레이터를 만들 때 거의 반드시 만나는 결정입니다 — 첫 화면은 미리 계산한 결과로 즉시 그리고, 상호작용이 시작될 때만 실제로 돌립니다.
메모리 쪽도 짚어 둘 만합니다. wasm32의 주소 공간은 4GiB지만, 실제로 한 탭에서 안정적으로 잡을 수 있는 선형 메모리는 그보다 훨씬 작고 브라우저·플랫폼별로 다릅니다. 모바일 사파리는 특히 인색합니다. 게스트 RAM을 크게 잡는 에뮬레이터는 여기서 먼저 막히고, 그래서 v86의 데모들이 대부분 128MB 안팎의 옛 시스템인 것도 우연이 아닙니다.
뜻밖의 이득 — 결정성
브라우저 시뮬레이터를 만들면서 얻는 부수 효과 중 가장 값진 것이 이것입니다. WASM 타깃은 스레드가 없고, 시간은 호스트가 주고, 난수도 호스트가 줍니다. 그래서 결정성을 얻기 쉬운 환경입니다. 그리고 결정적인 시뮬레이터는 그 자체로 회귀 테스트가 됩니다.
ccsim이 이걸 끝까지 밀어붙인 사례라 재료가 그대로 참고가 됩니다. 저장소가 밝힌 결정성의 구성 요소는 네 개입니다.
- 가상 클록 하나. netstack 타이머, 링크 이벤트, 애플리케이션 쓰기, 샘플링 틱, 시나리오 주입이 전부 하나의 최소 힙에 올라가고 동률일 때는 FIFO로 깨집니다. 시간의 출처가 정확히 하나입니다.
- 인라인 디스패치. 모든 netstack TCP 처리를 이벤트 루프 고루틴에서 동기적으로 처리하도록 gVisor를 패치했습니다. 어떤 고루틴도 클록과 경쟁하지 않습니다.
- 이름 붙은 PCG 서브스트림. 링크 손실(정방향/역방향), RED 결정, 도착 시각, 각 스택의 난수원, 흐름별 BBR 프로브 지터가 각각 별도의 서브스트림을 갖습니다. 한 곳의 호출 횟수가 바뀌어도 다른 곳의 난수열이 흔들리지 않습니다.
- FMA 융합 차단. 시뮬레이션 경로의 모든 부동소수 곱셈-덧셈에 명시적
float64변환을 넣어 컴파일러가 FMA로 융합하지 못하게 했습니다. arm64와 amd64와 wasm이 같은 비트를 내기 위해서입니다.
이 네 번째가 특히 시사적입니다. 크로스 플랫폼 결정성을 원한다면 부동소수 연산의 융합 여부까지 통제해야 한다는 뜻이고, 대부분의 프로젝트는 여기까지 가지 않습니다. 그 대가로 얻은 것은 명확합니다 — 네이티브 빌드와 WASM 빌드의 샘플 스트림이 바이트 단위로 동일하고, 그것이 테스트로 강제됩니다. 브라우저에서 본 그래프와 CI에서 돌린 결과가 같은 물건이라는 보장입니다.
교보재로서 이것이 왜 중요하냐면, "내 브라우저에서는 다르게 나오는데요"라는 질문이 애초에 성립하지 않기 때문입니다.
좋은 교보재와 장난감을 가르는 선
브라우저 시뮬레이터가 실제로 가르치는 물건이 되려면 몇 가지가 맞아야 합니다. 실패하는 쪽과 나란히 놓으면 선이 보입니다.
진짜 구현을 돌리는가, 아니면 애니메이션인가. ccsim은 gVisor의 실제 TCP 코드를 돌립니다. 그래서 SACK 스코어보드가 100개 범위에서 넘칠 때의 동작, RACK 재정렬 판정, ECN 에코 같은 것이 근사가 아니라 실물입니다. 반대로 슬라이더를 움직이면 미리 그려 둔 곡선을 보간하는 물건은 "혼잡 제어가 이렇게 생겼다"는 인상을 줄 뿐이고, 예상 밖의 조합에서 진실을 말하지 않습니다. 예상 밖의 조합에서 진실을 말하는 것이 시뮬레이터의 유일한 존재 이유입니다.
검증되는가. ccsim은 CUBIC 성장 곡선을 RFC 9438의 삼차 함수에 맞춰 보고 결정계수를 기록하고, RED 마킹 곡선을 카이제곱으로 검정하고, 골든 스트림 회귀 테스트를 돌립니다. 검증되지 않은 시뮬레이터는 잘못된 직관을 자신 있게 심어 줍니다. 아무것도 안 배우는 것보다 나쁩니다.
경계를 밝히는가. 흐름이 하나뿐이고, 병목이 하나이고, 미들박스가 없고, CPU와 인터럽트 처리가 없다는 사실을 말해 주는 시뮬레이터와 말해 주지 않는 시뮬레이터는 다른 물건입니다.
시작이 빠른가. 8MB를 받고 30초를 기다려야 첫 그림이 나오는 교보재는 아무도 안 봅니다. 미리 계산한 기본값으로 즉시 그리는 선택이 교육적 가치를 절반쯤 좌우합니다.
반대로, 이 방식이 장난감으로 끝나는 전형적인 조건도 명확합니다. 실제 하드웨어 타이밍이 본질인 주제(캐시 계층, NUMA, 인터럽트 지연)를 브라우저에서 시뮬레이션하면 배우는 것보다 오해하는 것이 많습니다. 규모가 본질인 주제(수천 흐름의 상호작용, 대규모 클러스터의 꼬리 지연)도 마찬가지입니다. 그리고 실제 네트워크와 상호작용해야만 의미가 있는 주제라면, 앞서 본 대로 릴레이가 시뮬레이션 대상을 오염시킵니다.
한 문장으로 줄이면 이렇습니다 — 브라우저 시뮬레이터는 "닫힌 계에서 규칙이 어떻게 상호작용하는가"를 가르치는 데 강하고, "실제 세계가 얼마나 지저분한가"를 가르치는 데 약합니다.
마치며 — 링크 하나로 배포되는 실행 환경
정리하면 이렇습니다.
- Wasm 3.0의 예외 처리, Memory64, WasmGC가 "계산 상자"였던 WASM을 시스템 소프트웨어가 돌 수 있는 실행 환경으로 바꿨습니다.
- 원시 소켓이 없다는 제약이 설계를 두 갈래로 나눕니다. 네트워크를 통째로 시뮬레이션하거나(ccsim), 릴레이로 터널링하거나(v86, WebVM). 가르치는 것이 목적이면 전자입니다.
- 진짜 병렬 실행은
SharedArrayBuffer에, 그것은 COOP·COEP 헤더에, 그것은 서버 통제권에 달려 있습니다. Go 타깃이라면 이 논의 자체가 없습니다 — 단일 스레드입니다. - Go는 크고(실측 8.66MB, gzip 2.35MB) 시작이 느리지만, 이식하려는 코드가 이미 Go라면 그것이 결정적 요인입니다. 실행 속도는 네이티브 대비 5배 안팎을 각오해야 합니다.
- WASM 타깃의 제약(스레드 없음, 호스트가 주는 시간과 난수)이 결정성을 얻기 쉽게 만듭니다. 결정적 시뮬레이터는 그 자체로 회귀 테스트입니다.
그리고 이 모든 제약을 감수할 만한 이유가 하나 있습니다. 설치도 계정도 클러스터도 없이, 링크 하나로 실행 가능한 시스템을 배포할 수 있다는 것입니다. 교보재로서 그 성질을 이기는 것은 별로 없습니다.
참고 자료
- Simulating TCP loss and congestion in browser using Go/WASM — 해커뉴스 (item 49088098)
- apoxy-dev/ccsim — 결정성 설계, WASM 패리티, 성능 수용 기준
- BBRv3 for gVisor's netstack — 만든 쪽 기술 블로그
- copy/v86 — x86 PC 에뮬레이터와 x86-to-wasm JIT
- leaningtech/webvm — CheerpX 기반 브라우저 리눅스 VM
- Mini.WebVM — Dockerfile에서 브라우저 리눅스 박스 만들기
- WebAssembly 3.0 릴리스 발표 (2025-09-17)
- MDN — SharedArrayBuffer와 교차 출처 격리 요구사항
- Go Wiki — WebAssembly 타깃 안내
- TinyGo — 바이너리 크기 최적화 가이드
- 브라우저 밖의 WebAssembly (관련 글)
- 브라우저에서 진짜 엔진이 돈다: WebAssembly 개발 도구 모음 (관련 글)
Simulating Systems in the Browser — What WASM Makes Possible and What Still Blocks It
- 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)