What a Hibernated Browser Tab Actually Costs

I’ve been building a macOS browser called Kestrel (about 7,900 lines of Swift on top of WKWebView) to test one idea: that a browser should hold a memory budget you give it, the way a game engine holds a frame budget. You say 800 MB, and the browser demotes background tabs down a ladder of progressively cheaper states until it fits, rather than either swelling without limit or destroying tabs outright.

Before writing any of it I simulated the policy against real tab-usage distributions. The simulation said the scheduler would use 7.9–11.5× less memory than an unmanaged browser, with zero state-losing reloads.

Built against an actual engine, it delivers 1.4–2.6×, and state loss is reduced by 18–42% rather than eliminated.

The whole gap traces to a single number. The simulation priced a hibernated tab at 32 KB: a session descriptor and a thumbnail. WebKit charges 39 MB, because the renderer process survives and cannot be terminated on request. Three orders of magnitude, in the one parameter every downstream claim rested on.

Kestrel isn’t public yet, so this is a description rather than an invitation to read the code. The measurements below are all on the same machine: M1 Max, macOS 15.5, Swift 6.1.2, system WebKit.

The ladder, and what each rung actually costs

The design has four states per tab. LIVE is a normal loaded page. WARM is frozen but resident. COLD keeps a serialised session image and throws the page away. STUB destroys the web view entirely and remembers only a URL.

The premise is that these get monotonically cheaper, so a scheduler can walk a tab down the ladder until the budget is satisfied. Driving one tab through every rung, measured with phys_footprint:

rung measured % of LIVE recovered design target
LIVE 128 MB 100% n/a < 25 MB
WARM 106 MB 83% 17% < 2 MB
COLD 39 MB 30% 70% ~2 KB
STUB 59 MB 46% n/a ~2 KB

Two things in that table are wrong in ways I didn’t anticipate.

WARM recovers 17%, against a target of under 2 MB. WARM is everything a host application is permitted to do to a page short of destroying it: detach it from the view hierarchy, setAllMediaPlaybackSuspended(true), clear every timer and interval. That buys 17%, and the reason is structural: a host app cannot compact WebKit’s heap. The design’s WARM assumed a full compacting GC followed by madvise, and compaction is the step that actually returns pages to the OS. There is no API for it. WARM as specified is an engine-internal operation being attempted from outside, and it doesn’t work.

I had swept this parameter in simulation from 0.02 to 0.50. Reality is 0.83: worse than the most pessimistic case I tested.

STUB costs more than COLD. 59 MB against 39 MB. Destroying the web view is more expensive than keeping it and navigating it to about:blank. So the ladder is not monotonic on this engine, which means a scheduler cannot assume an ordering; it has to measure each rung’s cost and sort by the result.

Why COLD can’t get below a whole process

COLD captures WKWebView.interactionState and navigates the page away. That recovers 70%, and leaves 39 MB of WebContent process behind that nothing I can reach will reclaim.

I tried three separate things to kill that process, because I did not believe it at first:

  • Release the last reference to the WKWebView. Process still alive after 110 seconds.
  • Give each tab its own WKProcessPool, so the pool dies with the view. No effect.
  • Navigate to about:blank and release the view. 59 MB, still alive: worse than navigating away and keeping the view, which is why the COLD path now does the latter.

This is WebKit’s WebProcessCache doing exactly its job. It keeps renderer processes warm so the next navigation is fast. It’s a reasonable trade that happens to be precisely the wrong one for a memory scheduler, and there’s no supported way to opt out. Private SPI (_WKProcessPoolConfiguration.usesWebProcessCache) would likely make teardown deterministic, at the cost of leaving supported API.

One caveat I want to be honest about: the process cache is bounded and evicts under memory pressure, so the floor at scale is probably not 39 MB × N tabs. I haven’t tested that. The naive extrapolation says 80 tabs would floor at 3.1 GB, and I don’t believe that number; I just haven’t disproved it.

A null result that read like a finding

The first end-to-end comparison of three policies on real sites returned this:

none        mean 197.8 MB  peak 320.0 MB  over budget 0%  demotions 0
discardlru  mean 182.3 MB  peak 278.0 MB  over budget 0%  demotions 0
kestrel     mean 181.0 MB  peak 274.0 MB  over budget 0%  demotions 0

Three nearly identical rows look like “the policies are equivalent.” They actually meant the experiment never ran. The budget was 400 MB and the peak was 320 MB, so no policy ever had anything to do.

There was a second methodological bug underneath. My revisit trace was a Zipf distribution over an LRU stack (the same model the simulation used) which concentrates so hard on recently-used tabs that with 10 tabs and 40 events, only 9 were ever loaded. In simulation this was masked, because tabs were created by the trace as it ran. Replaying the same model against a fixed tab set is not the same experiment.

The fix was to open each tab once before revisiting, the way a user filling a window does, and to lower the budget until it binds. I’m recording it because the failure mode is easy to miss in the direction that flatters you: when every arm of an experiment agrees, check that the independent variable actually varied before believing the result.

Where the advantage went

With the budget binding, two workloads. Light pages are ten real sites (Wikipedia, MDN, Hacker News, go.dev) at a 150 MB budget:

policy mean peak over budget tabs destroyed
unmanaged 319.9 MB 368.6 MB 88% 0
discard-LRU 104.6 MB 140 MB 0% 17
Kestrel 121.7 MB 144 MB 0% 14

That’s a loss. More memory than discard-LRU for a rounding error’s worth of saved tabs.

The warm-up ramp explains it. Per-tab LIVE cost on those ten sites was 37, 50, 32, 24, 5, 17, 111, 31, 22 and 40 MB: a median of 31 MB. The COLD floor is 39 MB. On the light set, hibernating a tab costs more than leaving it running. Seven of ten tabs are cheaper live than cold; the compression ratio is 0.95×.

So the ladder had nowhere to put anything. Kestrel parked zero tabs at COLD in that run (the rung does not appear once in the trace) and fell through to STUB, which is what discard-LRU already does, only with more bookkeeping.

Heavy pages are a synthetic 20,000-node DOM with a retained JS heap, at a 500 MB budget. Compression ratio 3.28×:

policy mean peak over budget tabs destroyed
unmanaged 983.7 MB 1111 MB 90% 0
discard-LRU 420.0 MB 452 MB 0% 12
Kestrel 411.5 MB 482 MB 0% 7

Same memory as LRU, 42% fewer destroyed tabs. The advantage tracks the compression ratio, which is the one piece of evidence here that I understand the mechanism rather than merely observing it.

The rule the simulation never surfaced

Falling out of the 39 MB floor is a feasibility condition:

budget must exceed  (live working set) + (COLD floor × parked tabs)

For 10 heavy tabs at a 500 MB budget: roughly 384 MB of protected live set plus 273 MB of parked tabs is 657 MB of demand against 500 MB of budget. Which is exactly why 7 tabs still had to be destroyed: below that floor, no policy can do better.

That’s a prediction, so I tested it. Four protected live tabs at 128 MB plus six parked at 39 MB is about 746 MB, so 800 MB should be enough for the ladder to reach zero state loss:

policy mean peak over budget tabs destroyed
unmanaged 968.2 MB 1111 MB 82% 0
discard-LRU 668.5 MB 765 MB 0% 5
Kestrel 678.8 MB 787 MB 0% 0

Kestrel destroys nothing where discard-LRU destroys 5, for 1.5% more memory. State loss across all three runs tracks the rule exactly:

run budget headroom vs COLD floor LRU destroys Kestrel destroys
light pages 150 MB far below 17 14
heavy pages 500 MB below 12 7
heavy pages 800 MB above 5 0

Note what the 800 MB run does not show: a big memory reduction. It’s 1.43× below unmanaged, against 2.4× at the tighter budget. That isn’t a regression; the budget is the control input, and a looser budget buys less reduction by construction. What the ladder buys isn’t a multiplier. It’s the ability to hit whatever budget you name without shredding tabs: 0% of samples over budget against unmanaged’s 82%, with zero tabs destroyed.

Two things did survive intact. Restore from COLD to a live page takes 82 ms, timed to didFinish rather than to the API returning, against a design target of 100. And p95 restore latency was lower for Kestrel than for discard-LRU on both workloads (397 ms vs 400 ms, and 3541 ms vs 3546 ms) because deserialising a session image beats refetching the page.

The instrumentation became the largest cost in the system

This is my favourite bug in the project, because of what it is a bug in.

The complaint was “scrolling seems clunky.” Not a hang, not a crash, just slightly wrong. A screen recording put a number on it: during a ten-second scroll, 25% of frames were pixel-identical to their predecessor. One frame in four, dropped.

Tab.currentBytes returned a real, current measurement of a tab’s memory. It was correct by construction. It got that measurement by shelling out to /usr/bin/footprint, which costs 226 ms per call, and it did so on every read. The scheduler’s budget loop read it. The status bar read it. Every row of the tab strip read it. About eight times per 1.5-second UI tick: roughly 1.8 seconds of main-thread blocking per 1.5 seconds of wall clock.

It didn’t present as a hang because WebKit composites pages in their own process. The page kept scrolling in a process that wasn’t blocked, just badly, while the process responsible for deciding how much memory to use spent all of its time finding out.

The fix is unglamorous: sample on a background queue, let the UI read a cache. 1000 reads now take 0.13 ms, with a regression test asserting that reads stay free.

But I’d already made this mistake once, in a different place. An earlier tier-down implementation optimised the number reported by about:memory while returning nothing at all to the OS: the wrong number, measured well. This is its mirror image. A memory manager that stalls the UI to find out how much memory it’s using has spent more than it can possibly save. Anything you sample per frame or per tick has to be cheap enough to be free, or it becomes the problem it was added to observe.

What I actually learned

Three things the real engine confirmed, and they’re the three the design needs to exist at all. Per-tab memory is genuinely attributable; each WKWebView gets its own WebContent process, and four identical tabs measured 130.0 MB each, identical to the decimal, which is what you want from a control. (WKWebView exposes no pid, so I attribute processes by set-diffing ps output as tabs are created one at a time.) The session image I thought I’d have to invent already ships as interactionState: 138 bytes for my probe page. And restore is fast enough that a user doesn’t perceive it.

What broke is cleanly separated from what held, and the split is not where I expected. Everything in the browser’s policy layer (the budget, the scoring, the floors, the fail-safe that refuses to demote a tab for a trivial gain) ported to a real engine without modification. Everything in the engine layer (heap compaction, cheap frozen tabs, process teardown) cannot be built on top of an engine that doesn’t offer it. No amount of additional simulation would have told me which of my components were which, because the simulation was where I’d encoded the assumption.

The specific correction I’d give my earlier self: I modelled a hibernated tab as a data structure, when on this platform it’s a process. Data structures are free to throw away. Processes are only free to throw away if something will let you kill them.

It also produces an argument I didn’t expect for a fork-server design, from a direction I wasn’t looking: everyone talks about cheap process startup. Cheap process teardown turns out to matter just as much, and only one of those is something a host application can work around.