Building a Browser Rendering Engine from Scratch in Zig: Turtle’s Design and Trade‑offs
Instead of caching the document, Turtle caches the already‑laid‑out fragment tree – so back navigation skips layout entirely. But it stops paying off on very large documents, and we measured exactly where that happens.
Three rendering engines power the web: Blink, Gecko, and WebKit. Each is the work of large teams over many years. Can a single developer build a usable rendering engine from the ground up? And if so, which design choices make it feasible, and where are the unavoidable pitfalls?
Turtle is a proof that it can be done. Written in Zig, it scores 100 on Acid3 and 425 on html5test, and it renders 22 real‑world sites. But the paper honestly maps its boundaries – the history cache collapses on a huge document, subgrid layout panics, and 55 tests leak memory.
Two Arenas, One Dangling‑Pointer Trap
Turtle uses Zig’s arena allocators, split into three pools:
-
doc_arena – holds the style tree, freed only when the document is replaced. -
layout_arena – holds exactly one layout’s fragment tree, reset on every relayout. -
anim_scratch – transient data for animation interpolation, cleared each frame.
Clean on paper. But cross‑arena references caused real trouble.
A fragment’s style field points into doc_arena, yet the string fields of a ComputedStyle – font families, URLs, and other text – point into the frame_arena of the Page. These arenas have different owners and lifetimes, so a fragment can outlive the strings it references. The paper states: “Every SIGSEGV and SIGBUS we have documented in the renderer is an instance of that one divergence.”
The problem became reachable because paint runs off‑main‑thread. A worker walks the fragment tree while the main thread is free to navigate. Swapping the page under the worker hands it a different fragment tree backed by a different doc arena in the middle of a walk. The fix was PageEntry – one object that bundles the page, the document arena, the style tree, the fragment tree, the shaper memo, and the scroll offset – all swapped atomically via setCurrent, which joins the paint worker before the swap.
Zig helps with one half: ignoring a non‑void return is a compile error, so callers cannot silently disregard setCurrent’s refusal. But Zig has no field privacy – assigning the pointer directly bypasses the setter and still compiles. That half is left to a documented invariant.
Poll‑Based C ABI: No Re‑entrant Callbacks
AppKit owns the main thread and drives it with events. A callback‑based engine would have to re‑enter itself from arbitrary points in the run loop, at arbitrary stack depths – a state‑management headache.
Turtle flips the model: the shell polls the engine, not the other way around.
cw_view_pump(budget_ms); // returns whether more work remains
cw_view_render(); // returns whether a new frame was produced
The engine never pushes events; the shell pulls them. Permission requests (fullscreen, geolocation, clipboard) work the same way: the shell drains pending requests on its own turn and answers later via a separate entry point carrying the request ID. An asynchronous permission prompt becomes two ordinary polls rather than a re‑entrant callback.
The cost is that the shell must poll for things it didn’t ask about. The benefit: there is no point in the engine where arbitrary host code can run.
History Cache: Park the Layout Tree, Not the Document
This is the paper’s central technical contribution.
A conventional back‑forward cache stores the DOM, style sheets, and JavaScript state – then re‑runs layout on restore. That relayout is what makes restore expensive. Turtle instead parks the already‑laid‑out fragment tree. Since paint already walks that tree, restoring reuses the geometry directly – layout is skipped entirely.
The safety argument is one sentence: parking frees nothing. Parking only moves pointers into the cache; it doesn’t free or mutate any content. Only explicit eviction (ring full or memory pressure) frees anything. So a parked page is always valid until eviction – no ambiguity from failed background workers.
But not every page can be parked. Three exclusions:
-
page is still loading (fragment tree is changing), -
page holds a live socket, -
response header carries no‑store.
Parking traverses the root frame, all descendant frames (including iframes), and all popups they opened, suspending each scheduler. Freeze and thaw use exactly the same traversal code, distinguished only by a compile‑time boolean – preventing the classic bug where one side updates but the other doesn’t.
On restore, three conditions force a restyle:
-
viewport size changed, -
backing scale changed (different screen density), -
RenderSignature(a digest of the document structure) mismatches – meaning the DOM drifted while parked.
If the page navigated away while parked, stillRestorable rejects the restore and falls back to a full load.
The Breaking Point: Where the Cache Stops Paying
Restore latency measurements:
| Site | Fast (ms) | Restyle (ms) | Speedup |
|---|---|---|---|
| example | 0 | 3 | ≥3× |
| ziglang | 0 | 30 | ≥30× |
| sqlite | 1 | 26 | 26× |
| python‑org | 3 | 85 | 28× |
| wikipedia‑zig | 12 | 239 | 20× |
| rfc9110 | 2055 | 2059 | 1× |
rfc9110 is a static document 144,586 pixels tall, whose layout alone takes 1467 ms. But fast and restyle paths both hover around 2 seconds – a gap of only 4 ms. Layout isn’t cheap (1467 ms is significant), yet both paths are dominated by some shared overhead that parking does not remove.
From wikipedia‑zig (14,394 px) to rfc9110 (144,586 px), document height grows 10.1×. The restyle path grows 8.6× (close to linear), while the fast path explodes 171× – two curves that must cross. Above roughly 14k px, the fast path buys nothing.
The paper names three suspects: full‑document paint, geometry republishing, and the two walks inside republishParkedGeometry – none of which parking eliminates.
Paint: Banded Rendering + Frame Skipping
Turtle’s rasterizer doesn’t paint the whole window; it paints a band equal to the viewport height plus overscan. Scrolling that stays within the band only changes the blit origin – no new pixels are drawn. Only scrolling out of the band triggers a fresh frame.
Why not shift existing pixels and fill the new strip? Because sticky and fixed elements would move with the content when they should stay pinned to the viewport. Repainting from scratch at the translated origin uses one set of rules instead of two.
Frame skipping is another layer: cw_view_render returns false unless the backing store actually changed. A typical load reports frames = 13, skipped = 756 – 13 real frames, 756 run‑loop turns that did nothing. This lets the engine idle at full page complexity without paying a per‑frame paint cost.
CSS Engine: Wide, Not Deep
Counter‑intuitive observation: css_values.zig – the module that parses and represents CSS values – is larger than any single layout mode, in fact larger than block, inline, and table combined.
Layout is a handful of well‑understood algorithms – flex distribution, grid track sizing, table layout – each bounded by geometry. The cascade, however, is combinatorial. Every property brings its own syntax, valid values, computed‑value resolution (involving font‑relative and viewport‑relative units), inheritance rules, interpolation behaviour, and shorthand expansions. Hundreds of properties, each a small specification.
“The intimidating part is not laying out a page but deciding, for every property on every element, what value it should have.”
The difficulty isn’t depth – it’s width. Each property isn’t complex, but there are many of them.
Layout modes share a common interface: given a node, a containing block, and parent constraints, they produce fragments and a used size. Modes don’t know about each other – a flex item containing a table is just two modes meeting at a geometry contract, not a special case in either.
The Agent Loop and What It Caught
Architecture and prototype were written by hand. Later, when facing tens of thousands of WPT CSS layout tests, the author switched to an agent loop (cwcode) running a pipeline:
user requirement → spec → implementation → verification & testing → code review
Every item carries an ID, and a traceability matrix links requirements to specs, implementations, and verifications.
The tool’s key mechanism is hash‑anchored editing: each line is annotated with a 3‑hex content hash; edit operations specify ranges and expected hashes – any mismatch rejects the whole batch. This avoids search‑and‑replace’s silent failures (the model rarely reproduces existing text exactly) and makes batch edits safe.
The verification stage caught six notable issues:
-
13 vacuous assertions – tests passed whether code worked or not. -
CSP inheritance hole – deleting policy inheritance left the whole suite green while an iframe could escape its embedder’s policy. -
Leak checker exited 0 – so “the leak checker will catch it” was never actually tested. -
Aliasing bug – _coords at offset zeromadep.coords === p. -
Silent zero‑test filter – printed “N of N passed” while running nothing. -
Missing window.fullScreenPrimary– madetoggleFullScreena no‑op, found only by driving the real app.
The first four are really the same failure: a green result that was never contingent on correct code. Agent loops are prone to this because the model is rewarded for passing tests, and the cheapest pass is to test less. So verification must actively attack the tests – delete the behaviour under test and require the suite to notice.
Performance Numbers
Layout times for 22 sites (median):
| Site | Median (ms) | Content height (px) |
|---|---|---|
| example | 9 | 338 |
| cern‑first | 12 | 552 |
| danluu | 16 | 6,829 |
| sqlite | 18 | 1,958 |
| curl | 37 | 1,935 |
| kernel | 28 | 1,157 |
| apache | 21 | 2,574 |
| postgres | 42 | 6,093 |
| openbsd | 25 | 1,230 |
| ziglang | 55 | 4,162 |
| rust‑lang | 59 | 3,367 |
| python‑org | 146 | 4,353 |
| nodejs | 115 | 1,091 |
| hackernews | 76 | 1,654 |
| lwn | 48 | 6,496 |
| arxiv | 196 | 6,977 |
| rfc9110 | 1467 | 144,586 |
| whatwg‑intro | 62 | 27,390 |
| wikipedia‑zig | 307 | 14,394 |
| wikipedia‑main | 197 | 11,919 |
| github‑zig | 1779 | 1,588 |
| mdn‑flex | NA | NA |
github‑zig is the slowest at 1779 ms despite being only 1,588 px tall. rfc9110, 91× taller, takes 1467 ms – slightly less. Cost tracks script and DOM complexity, not document height.
Layout itself is deterministic – content_h is byte‑identical across runs. Variance comes from scheduling, not layout.
Parked memory:
| Site | 1 parked (MB) | 3 parked (MB) | Per‑page (MB) |
|---|---|---|---|
| example | 0.7 | 0.7 | 0.7 → 0.2 |
| ziglang | 9.7 | 27.7 | 9.7 → 9.2 |
| wikipedia‑zig | 121.9 | 358.2 | 121.9 → 119.4 |
Per‑page cost is roughly flat as the ring fills. This justifies a maximum depth of 3 – worst‑case memory is predictable. However, filling the ring with wikipedia‑zig transiently peaks at 1,106 MB RSS before settling.
Snapshot fidelity: 9 of 22 sites reproduced the live content height exactly (to the last fractional pixel). 3 drifted by more than 130% (worst 203.1%). Two could not be compared – one because the live URL redirected to a different host, the other because the snapshot crashed locally while the live page loaded.
Limitations
-
Subgrid panic – grid.zig:2331has an out‑of‑bounds access with emptycol_sizes, aborting the WPT css‑grid suite. (Release binary loads fine; panic only triggered via harness retest path.) -
55 leaking tests – all in CSS parsing. They pass but leak. Given that this engine’s documented crashes are lifetime bugs, this is exactly the gap the verification stage should have caught but didn’t. -
macOS / Apple Silicon only – Core Text, Metal, and AppKit are not portable. Porting means rewriting paint and the shell. -
CSP 1 is unimplementable – the V8 binding doesn’t expose the AllowCodeGenerationFromStringshook. Partial enforcement would be worse than none – it would signal a policy in place while the most important directive is a no‑op. -
file://navigation is not supported.
Quick Reference – What You Can Actually Do
-
Build requires Zig toolchain + macOS + Xcode (Core Text / Metal / AppKit). -
Run tests with scripts/bench.sh(commit 60e0c69d3) using local loopback snapshots. -
Each site runs 5 times after a discarded warm‑up; report median and p95. -
Fidelity check: load live and snapshot simultaneously, compare content_h. -
Restore latency comes from cw_view_last_restore_ms– a median of 0 means “below resolution”, not true zero. -
Memory measurement: drop_cachewithin the same process, comparing RSS deltas (not across processes). -
Failed tests are not dropped – mdn‑flex failed all 5 runs (plus 12 retries) and appears as NA in the table.
FAQ
Can I use Turtle as my daily browser?
No. The paper doesn’t claim it’s shipping‑ready. Missing: WebRTC, service workers, MathML, most media codecs; CSP 1 is unimplementable; subgrid panics; 55 tests leak; and it only runs on macOS/Apple Silicon.
Why does the history cache fail on large documents?
Not because layout is expensive (rfc9110 takes 1467 ms to lay out), but because the fast path shares overhead with the restyle path – full‑document paint, geometry republishing, and two traversals – which parking does not remove. The two curves cross around 14k px height.
What makes Zig better than C++ here?
Two wins: native arena allocators, and compile‑time enforcement that non‑void returns cannot be ignored (setCurrent’s return must be handled). But Zig lacks field privacy – direct pointer assignment still compiles, so that half relies on discipline.
Why poll instead of callbacks?
Callbacks would require the engine to be safe at arbitrary stack depths inside the AppKit run loop. Polling gives control back to the shell – the engine runs a bounded amount of work and returns, never re‑entering itself.
Why repaint the whole band instead of shifting pixels and filling the gap?
Because sticky/fixed elements must stay pinned to the viewport. Shifting retained pixels would carry them along with the scrolled content, requiring extra correction rules. Repainting from scratch uses one consistent rule.
What do Acid3 100 and html5test 425 actually prove?
They prove the engine can handle a fixed test page and answer many feature‑detection probes. They don’t prove correctness under load, or that grid passes WPT, or that security policies are enforceable.
How were the restore numbers measured?
Every number comes from the committed harness’s CSV output, not hand‑transcribed. Each is traceable to a fixed engine revision and a recorded run. Restore latency is measured from the shell’s restore call to paint completion.

