Headless browsers in smolvm — and pre-warmed browser pools via fork
browser.smolfile covers the basics: a GPU-accelerated headless Chromium you
create + start + exec against. This doc covers the harder-won part —
running Chromium as a persistent, forkable workload so you can keep a pool
of pre-warmed browsers and skip cold startup on every task.
Why fork a browser at all
A browser's cold start (process launch + shared-library load + JS-engine init + first navigation) costs 1–3+ seconds. A smolvm fork is a copy-on-write clone of a running VM, so a clone inherits the golden's already-running, already-initialized browser — same process, same warmed heap, same listening CDP port. You pay the cold start once (in the golden) and materialize warm clones in ~50–130 ms each.
warm one golden ──fork──▶ clone 1 (browser already up) ~90 ms to agent-ready
──fork──▶ clone 2 (browser already up)
──fork──▶ clone N ...
The #1 gotcha: fork-friendly Chromium flags
Chromium MUST be launched with --no-zygote (plus --no-sandbox and
--disable-dev-shm-usage) for the browser to survive a fork.
Chromium's default process model uses a zygote — a pre-forked template process
the browser clones renderers from. That model does not survive a
cross-process VM fork cleanly: after the fork, joining the restored container
(crun exec, which is what machine exec does for an image VM) hangs. With
--no-zygote, Chromium runs a flat process tree that forks and execs correctly
on the clone.
# fork-friendly:
chromium --headless=new --no-zygote --no-sandbox --disable-dev-shm-usage \
--remote-debugging-port=9222 --user-data-dir=/tmp/cdata about:blank
# NOT fork-friendly (the image's bare default CMD, zygote on):
# → golden runs fine, but `machine exec` into a forked clone hangs.
--no-sandbox is required because the microVM guest typically lacks the
user-namespace / setuid-sandbox plumbing Chromium expects; --disable-dev-shm-usage
avoids the tiny default /dev/shm.
Setup: a persistent, forkable browser golden
The browser has to be running at fork time, so launch it as the machine's
persistent workload — not via a one-shot machine exec (which is torn down when
the exec returns). Pass the command at create (an image machine with no
command instead adopts the image's OCI CMD/ENTRYPOINT):
smolvm machine create --name browser-golden \
--image chromedp/headless-shell:latest --net \
--workdir /headless-shell \
-- ./headless-shell --headless=new --no-zygote --no-sandbox \
--disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=/tmp/cd
# --forkable enables the CoW-fork machinery (memfd-backed RAM + control socket).
smolvm machine start --name browser-golden --forkable
# Fork a warm clone on demand (one golden → N clones):
smolvm machine fork --golden browser-golden --name worker-1
smolvm machine fork --golden browser-golden --name worker-2
# Each clone's browser is already up; exec / drive it:
smolvm machine exec --name worker-1 -- /bin/sh -c 'echo ready'
The golden stays frozen as the CoW base while clones exist — don't start
it again until the clones are gone.
What survives the fork (and what doesn't)
| Preserved (it's in the restored RAM/CoW disk) | NOT preserved |
|---|---|
| The running browser process + warmed heap / JIT | Live network connections (reset on restore — freeze the golden at an idle point, not mid-request) |
| Loaded shared libraries, parsed/blank page | GPU renderer context (host-side virgl/Venus state isn't transferred; headless / software rendering is unaffected) |
The listening CDP port (127.0.0.1:9222) |
Wall-clock-sensitive timers can jump forward by the freeze duration |
| Open file handles into the rootfs |
Per-clone identity is rejuvenated automatically (distinct hostname, fresh entropy), and each clone gets its own CoW disk overlay, so clones are isolated.
Driving the warm browser (CDP)
Chromium binds the DevTools endpoint to 127.0.0.1 regardless of
--remote-debugging-address (security hardening), so reach it from inside the
VM (the agent shares the guest network namespace) or proxy it out.
Use a real CDP/WebSocket client — puppeteer or chrome-remote-interface —
not raw curl/socat. A raw TCP client connects but won't get the DevTools
JSON body back reliably, and the WebSocket CDP path needs
--remote-allow-origins=*. The HTTP /json/* endpoints also require a
Host: localhost header (DNS-rebinding protection).
Performance
Measured on Apple M4 Max (APFS, instant clonefile CoW disks):
- End-to-end
machine forkto a usable, agent-reachable warm clone: ~50–130 ms. - Clone boot-from-snapshot to agent-ready: ~90 ms.
- Density: golden ~300 MB RSS, each clone only ~30–40 MB (RAM CoW-shared).
On Linux the per-fork time is the same class on a reflink-capable filesystem (btrfs/xfs); on ext4 the disk copy dominates (~0.4–0.9 s).
Status
Forking (--forkable, machine fork) is part of the fast-fork work and is
validated on both Linux/KVM (x86_64) and macOS/HVF (aarch64). The persistent
warm browser → fork → drive path is validated end-to-end with the flags above.
The cross-arch caveat is fundamental: a clone runs on the same CPU arch +
hypervisor + host as its golden (a fork is a live CoW clone, not a portable
snapshot). For portable, cold artifacts use smolvm pack instead.