Skip to content
C.W.K.
Lesson 02 of 05 · published

Sleep and Wake

~15 min · webkit, memory, tab-sleep, measurement

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Light means not running

Dad keeps a lot of tabs. A browser that keeps every one of them running pays for pages nobody is looking at, and a browser that closes them to save memory throws away the thing he kept them for. Voyager's architecture settles it in one invariant: light means not running, never removing. A tab out of view lets go of its web view once the sleep policy fires, keeps its place in the strip, and comes back where Dad left it.

So a sleeping tab is not a closed tab with a bookmark. It is the same BrowserTab, same id, same identity, same title and icon, with page set to nil and one extra thing held on the side: the page's place, as bytes.

The bytes

Since macOS 12 and iOS 15, WKWebView has a public interactionState property: an opaque value that captures where a page is. Voyager reads it into a PageState, which is two fields, the engine's name and the bytes. The parent never looks inside. It stores them and hands them back.

Before any policy was written, the engine gate checked that this actually works (case W9, 2026-10-03). A new web view given the old one's interactionState came back to the same address and scroll position, with its back list. The state for one page was 624 bytes. Written to a file, read back and given to a third web view, it restored the same place again. That last step is what lets a session file carry a tab's place across a relaunch, which is the next lesson.

The engine name on the bytes is a small piece of humility. If the bytes were written by another engine, the waking tab ignores them and loads the address instead. Voyager has one engine today, but the bytes mean something only to the engine that wrote them, so the label travels with them.

sleep() and wake()

Putting a tab to sleep is short: capture the place, clear what only a live page can claim (loading, progress, sound), then discard the page, which closes the web view and bumps contentRevision so nothing mistakes an old read for a current one.

Waking is the careful half:

  • Native pages (Home, Pippa herself) are never woken; they have no web page to get back.
  • An extension's own page (chrome-extension://) is rebuilt through the extension's configuration and never from saved bytes, and never in a private window.
  • Everything else gets a fresh page made in the tab's own identity, restoring the bytes when the engine wrote them.
  • The new page inherits the tab's mute and the zoom Dad chose for that site, so a woken tab is not suddenly loud or small.
  • With no usable bytes, the page simply loads the address.

Two small rules keep the place honest. A sleeping tab that is sent somewhere new drops its old place, because that is no longer where it should wake. And a hard reload (⇧⌘R) on a sleeping tab drops the place before it wakes, since asking for the page without its caches means starting fresh.

Measure the engine, then write the policy

How long should a tab wait before it sleeps? The answer came from measuring WebKit, not from guessing. WebKit ends a web content process whose memory passes a threshold, and the threshold depends on where the page is. Case W10 measured it on a Mac on 2026-10-03:

The pageWhere its web view wasWhat happened
6 GBout of the windowended after 567 s (alive at minute 8, gone at minute 9)
3 GBout of the windowlived the full 14 minutes
6 GBin the window, under another viewlived the full 13 minutes

So a page out of the window keeps the visible limit for about eight minutes and then falls under a lower one, and the limits turn on whether the web view is in a window, not on whether anyone can see it. That gave the design two levers. A heavy page that Dad is still using can stay in the window under the tab in front and keep the visible limit; that is how Pippa herself is kept, in lesson 4. Every other page is better asleep than killed, because sleep keeps the place and a kill keeps nothing.

The idle policy

Settings has tabSleepMinutes: 60 by default, offered as never, 15, 30, 60 or 120 minutes. Once a minute a timer asks every window to sleep its idle tabs, on the Mac and on the phone alike. A tab sleeps only if all of these hold: it is not in front; it has been behind for at least that long (the backgroundSince stamp from lesson 1); it holds a page, so Home and Pippa herself are never touched; it is not loading; it is not an extension's page; it is an http or https page; it is in the outside world, so a family app, which may be in the middle of a voice turn or an edit, never sleeps; and the engine does not call it busy.

Busy is the engine's call. WebKit's child says a page is busy when the camera or microphone is in use, when media is playing, or when the field Dad last typed into still holds text. That last check reads only whether the field is empty, never what it says.

Principle: Measure the engine, then design the policy. The number that matters here, about eight minutes and a lower line out of the window, was not in any documentation. One probe on one evening turned a guess into a rule.

Code

cwkVoyager, Sources/VoyagerCore/BrowserTab.swift: wake and sleep (verbatim, lines 110-144)·swift
    /// Gives a sleeping tab its page back, where it was when it can be.
    func wake(using engine: any BrowserEngine) {
        guard page == nil, !VoyagerPage.isNative(status.url) else { return }
        let isExtension = status.url.map { ["chrome-extension", "webkit-extension"].contains($0.scheme ?? "") } ?? false
        let state = isExtension ? nil : restorable.flatMap { $0.engine == engine.name ? $0 : nil }
        let fresh: any EnginePage
        var address = status.url
        if isExtension {
            guard !isPrivate, let url = address, let resolved = engine.extensionPageURL(for: url),
                  let extensionPage = engine.makePage(forExtensionPageAt: resolved, identity: identity) else {
                loadFailure = "This saved extension page is unavailable. Reopen it from the Extensions menu."
                return
            }
            fresh = extensionPage
            address = resolved
        } else { fresh = engine.makePage(identity: identity, restoring: state) }
        fresh.delegate = self
        fresh.isMuted = isMuted
        // Every page opens at the zoom Dad chose, for every page or for this site.
        if let settings = window?.services.settings.value { fresh.zoom = settings.zoom(for: status.url) }
        page = fresh
        restorable = nil
        processEnded = false
        if state == nil, let url = address { fresh.load(url) }
    }

    /// Releases the page and keeps the tab's place.
    func sleep() {
        guard let page else { return }
        restorable = page.captureState()
        status.isLoading = false
        status.progress = 0
        status.isAudible = false
        discardPage()
    }
cwkVoyager, Sources/VoyagerCore/BrowserWindowModel.swift: the idle policy (verbatim, lines 691-712)·swift
    /// Lets go of the pages of tabs that have been behind for `minutes` or
    /// longer (invariant 9: light means not running). A tab keeps its place
    /// and comes back where it was when chosen. Never put to sleep: the tab
    /// in front, a page still loading, a family app (it may hold live state:
    /// a voice turn, an editor), and a page the engine calls busy (playing
    /// sound, using the microphone or camera, holding text typed and not
    /// sent). Pippa herself and Home hold no page and are never touched.
    public func sleepIdleTabs(after minutes: Int, now: Date = Date()) async {
        guard minutes > 0 else { return }
        for tab in tabs where tab.id != selectedID {
            guard let since = tab.backgroundSince, now.timeIntervalSince(since) >= Double(minutes) * 60,
                  let page = tab.page, !tab.status.isLoading, page.extensionID == nil,
                  let origin = tab.status.url.flatMap(PageOrigin.init(url:)),
                  ["http", "https"].contains(origin.scheme),
                  services.trust.zone(of: origin) == .outsideWorld
            else { continue }
            let busy = await page.isBusy()
            guard !busy, tab.page === page, tab.id != selectedID, !tab.status.isLoading else { continue }
            tab.sleep()
            sessionChanged()
        }
    }
cwkVoyager, Sources/VoyagerWebKit/PageScripts.swift: an unsent draft keeps a page awake, and only its emptiness is read (verbatim, lines 29-45)·swift
      // The field Dad last typed into, for the sleep policy: a page holding
      // text he has not sent is not put to sleep. Only whether it is empty
      // is ever read (`unsentText`).
      document.addEventListener('input', (event) => {
        if (event.isTrusted && isEditable(event.target)) globalThis.__voyagerTyped = event.target;
      }, true);
    })();
    """

    /// Whether the field Dad last typed into still holds text: a draft the
    /// sleep policy must not throw away. Its text is not returned.
    static let unsentText = """
    const field = globalThis.__voyagerTyped;
    if (!field || !field.isConnected) return { busy: false };
    const text = field.isContentEditable ? (field.textContent || '') : String(field.value || '');
    return { busy: text.trim().length > 0 };
    """

External links

Exercise

Pick an app that holds many heavy views, such as a browser, an editor with many documents or a dashboard with many panels, and write its sleep policy as one pure function: given each view's state (in front or not, time behind, loading, playing, holding unsaved input, trusted or not), return the views to release. Then write the measurement you would need before choosing the time limit: what you would load, where the view would sit, and what result would change your number.
Hint
Keep the decision pure and the side effects outside it, so the edges are plain tests: a view behind for one second less than the limit, a limit of zero, a view that becomes busy during the check. For the measurement, vary one thing at a time. Voyager's W10 kept the page size fixed and changed only where the view sat, and that single change is what revealed the rule.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.