Hanafuda July card: bush clover with boar hwatu · blog

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:

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:

  1. Take the warm view; queue warming the next one.
  2. stop_loading() on it unconditionally.
  3. 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