What a Hibernated Browser Tab Actually Costs

CodeMegapixel99/kestrel

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 is public as of 17 August 2026, so every figure below can be read against the code and the results files it came from. The measurements are all on the same machine: M1 Max, macOS 15.5, Swift 6.1.2, system WebKit.

A re-measurement the day after this post went out has since retracted part of it, including the zero-state-loss result at 800 MB, because every per-tab figure here is an attributed sum and attribution undercounts once tabs are demoted. The correction is at the end, with what it left standing.

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.

Correction, 14 August 2026: the per-tab totals undercount, and not evenly

Under “what I actually learned” I described how memory gets attributed to a tab, set-diffing ps output as tabs are created one at a time, and treated it as an implementation note. It is not an implementation note; it is the measurement, and it is wrong in two ways. WebKit spawns several content processes at once, so the diff picks one of them arbitrarily. And a process that no tab claims, including one left behind when a tab is demoted, is counted as nothing at all.

Re-run with _webProcessIdentifier, so a web view names its own process rather than being matched to one, the benchmark records both the attributed sum and the browser’s real footprint (every WebContent process younger than the run, summed, which needs no attribution). Heavy pages, 800 MB budget, 40 events:

policy attributed measured tabs accounted for over budget: attributed → measured
unmanaged 975.9 MB 974.9 MB 100% 82% → 82%
discard-LRU 547.1 MB 802.2 MB 68% 2% → 62%
Kestrel 587.9 MB 788.1 MB 75% 2% → 48%

The 100% row is the one that makes the rest a finding rather than noise. With every tab live and every process owned by a tab, attributed and measured agree to within 1 MB, so the 25 to 32% gap under the two demoting policies is not measurement error: it is real memory in processes that outlived the tabs they belonged to. Demoting a tab releases its web view, WebKit keeps the process, and nobody owns that memory. The error therefore favours exactly the policies this benchmark exists to promote, which is the worst direction for it to point.

So the 800 MB headline does not survive. The reduction is 1.24×, not the 1.43× I reported, and “0% of samples over budget” is really 48%, because the scheduler was demoting until its own accounting said it was under the bound, which is not the same as being under the bound. The re-run destroys 6 tabs where the run above destroyed none, against discard-LRU’s 8.

The light-pages run inverts outright. Measured, eleven mixed-weight sites on a 150 MB budget cost Kestrel 345.9 MB against 255.3 MB for managing nothing at all, which is 1.35× worse, and discard-LRU 414.8 MB, 1.62× worse; the 2.4 to 2.6× I reported for that run is retracted in full. What makes this the most useful part of the correction is that it is this design’s own predicted failure, finally measured: the feasibility rule says eleven parked tabs need 429 MB before a single page is displayed, and that run was given 150 MB, 2.9× below its own floor. What I predicted below the floor was degradation toward discard-LRU. What happens is degradation below doing nothing, because every demotion strands a process and every restore spawns another, and the churn costs more than the parked pages save.

What survives is the comparison, and it is now on firmer ground than the attributed numbers left it: Kestrel holds 788 MB against discard-LRU’s 802 MB while destroying 6 tabs to its 8 and spending 48% of samples over budget to its 62%. On the attributed figures it looked marginally worse on memory and better only on state loss. The ladder’s own physics survive too, re-measured to the decimal (128.0 MB live, 106.0 MB warm, 39.0 MB cold, 87 ms to restore), so the 39 MB figure that this post is named after, and the feasibility floor built on it, stand unchanged. What was wrong was never the price of a hibernated tab. It was the claim about what the ladder does to a browser.

One smaller thing to reconcile against the repository, since it is now public and the first paragraph of this post is checkable in a way it was not on the 13th: the browser is about 5,500 lines of Swift there, not the 7,900 written above. It grew to roughly 10,000 first, half of that imitations of extensions (an ad blocker, dark mode, userscripts, a password manager, a screenshot tool), and once WebKit’s own WebExtensions runtime made real Firefox add-ons load, the imitations were deleted. The add-ons are worth their own number: the eight in the active profile add 244 MB to a 50 MB browser, which is memory the ladder cannot touch at all, because a background page belongs to no tab.

Which makes this the same bug as the tier-down two sections up that improved about:memory while returning nothing to the OS: the wrong number, measured well, again, at the scale of the whole benchmark rather than one rung. I did not recognise it until a screen recording showed the browser reporting 52 MB for a page whose content process held 511 MB. The full accounting, including the per-event traces, is in RESULTS-ENGINE.md, and what a browser should do when it can prove its budget is unreachable (refuse the budget, name the number of tabs it can hold, stop demoting) is in BROKEN.md rather than in the code.