Why hwatu's browser windows spawn in 13 ms
hwatu is a verification browser for AI coding agents: a daemon that renders real pages with WebKitGTK and answers questions about them (pixel diffs, animation numbers, console errors) over a Unix socket. Its windows spawn in 13 ms median, and a full verification pass (open a page headless, wait for load, eval JS, screenshot, close) is one command and ~35 ms. This post is about where those milliseconds go and, mostly, where they don't.
The split
hwatu is two binaries, split the way emacs/emacsclient or wezterm/wezterm-cli are:
-
hwatud, the daemon: a single-threaded GTK4 application that owns WebKitGTK 6, every window, and all state. 1.1 MB. -
hwatu, the client: 624 KB, no GTK linkage at all. It connects to$XDG_RUNTIME_DIR/hwatu.sock, writes one line of JSON, reads one line back, and exits.
The protocol is deliberately primitive: one request per connection, newline-delimited JSON, no sessions, no WebSocket, no client library. That primitiveness is a feature twice over. It makes the client process nearly free (fork, connect, one roundtrip), and it makes the browser drivable from anything that can write to a socket, which for AI agents means the invocation cost is a few tokens, not a schema.
Where browser startup time actually lives
Launch a browser engine cold and you pay for: process spawn, library loading (WebKitGTK maps ~300 MB of shared libraries), the network process, the sandbox setup (bwrap, dbus proxies), GPU context creation, and the first WebView's web process. On my machine that's 181-407 ms before a window can exist.
The entire trick of hwatu is that this cost is paid exactly once, at daemon start, and never again. Everything after that is bookkeeping.
The prewarm pool
The daemon keeps exactly one spare WebView, built during idle time
via glib::idle_add_local_once. Crucially it's not just
constructed, it's deep-warmed: the pool loads
about:blank into it, which forces WebKit to realize
the web process and the GPU compositor path while nobody is
waiting. Without that, the first paint of a new window would still
pay pipeline setup.
Opening a window is then:
- Take the warm view; queue warming the next one.
stop_loading()on it unconditionally.- Wrap it in a GtkWindow, register the id, start the real navigation.
Step 2 earns its comment in the source. A view adopted mid-warm has
a pending about:blank load whose Started/Finished
signals are indistinguishable from a real navigation's. Left alone,
the stale load's Finished can satisfy a later
wait_load before the real page has committed, and the
caller's eval results get destroyed by the real commit
moments later. Warm pools create time-travel bugs; you have to
cancel the past explicitly.
Result, measured over 20 runs (scripts/bench-spawn.sh,
which fails if the median exceeds a budget): 13 ms median for a
focused window, 14 ms headless, 8 ms best case. Headless is
steadier (p90 15 ms vs 35 ms) because there's no
compositor activation request in the path, no window manager in the
timing at all.
Headless as a window property
In every mainstream tool, headless is a launch flag: decide at startup, live with it. In hwatu it's a property of an individual window, because of how the windows are made. A headless window is realized, the xdg_toplevel exists, but never mapped, the compositor never shows it. WebKit won't lay out a zero-size view, so hwatu pushes a manual allocation at the requested viewport and the page renders normally, GPU-composited, screenshot-able, just invisible.
hwatu focus <id> later maps that same window
into the user's tiling WM: same cookies, same scroll position, same
half-filled form. This is the hand-off flow the whole design
serves: an agent works invisibly, hits a CAPTCHA or a judgment
call, and hands the live session to a human for ten seconds.
Launch-time-headless architectures cannot do this at any price, the
offscreen target has no window to map.
Making the whole check one roundtrip
Fast spawns weren't enough. An agent's verify loop (open, wait,
eval, screenshot, close) as five CLI calls cost ~103 ms, most of it
process spawns and socket roundtrips. hwatu check runs
the whole sequence daemon-side: one client, one JSON reply carrying
title, eval result, screenshot path, console errors, and timings.
Finished check windows don't die, they park: blanked,
console drained, 60-second TTL, max two, and the next check
navigates a parked window instead of building one. That took the
pass to 39 ms via CLI, 35 ms over a held socket.
For calibration: the same pass through Playwright's best case (warm, in-process CDP) is 82 ms. Shaped the way shell-driven agents actually run, a fresh client each check against a kept-warm browser server, Playwright pays 341 ms, almost all Node startup and WebSocket connect. hwatu's whole design is being that warm service. Fairness requires saying Playwright wins cold start (190 vs 435 ms) and memory, and headless-shell Chromium is not the sluggish giant folklore claims. Full methodology and every caveat: docs/benchmarks.md in the repo.
The lesson generalizes past browsers: if a tool is invoked far more often than it's started, make it a daemon, keep the expensive state alive, and make the client so dumb it can't be slow.
hwatu is AGPL-3.0-licensed Rust, Linux/WebKitGTK 6: github.com/hongnoul/hwatu