visar.log
Technical notes from building things
← all posts

Borrowing Safari's Memory: bfcache and scrollend + 100ms

Two Kinds of Continuity

My gallery and manga readers are userscripts. They do not run at an origin I control. They take over somebody else’s page, remove its interface, and build my application in its place.

That sounds like a small implementation detail until navigation begins.

Search, gallery reader, manga chapter, and Favorites are real website routes. Every normal navigation destroys one userscript application and starts another. I still want the result to feel like a single iPhone app:

  • swipe Back from the reader and find the previous screen exactly as I left it;
  • reopen or reload a reader URL and return to the same image;
  • scroll across a manga chapter boundary without losing where I am;
  • paginate searches without filling Safari history with meaningless steps.

Two Safari behaviors became the foundation of that experience:

  1. bfcache preserves the application across Back navigation.
  2. scrollend + 100ms converts the reading position into a durable URL.

One preserves a whole screen. The other preserves a point inside a long stream of images.

bfcache Is Not an HTTP Cache

The back-forward cache, usually called bfcache, is an in-memory snapshot of a page. Safari freezes the DOM, JavaScript heap, layout, form values, and scroll position when navigating away. If the page remains eligible, Back does not load it again. Safari thaws the previous page.

This distinction matters for a userscript. If Safari restores from bfcache, the userscript does not execute from the beginning because there is no new page load. The application I constructed is still there.

search page
    ↓ tap a gallery
reader page
    ↓ swipe Back
the same search DOM, JS state, modal state, and scroll position

On Hitomi this feels remarkably native. I can open an information modal, follow one of its related searches, paginate that search, and swipe Back. The previous gallery list returns with the modal still open underneath. Safari preserved the interaction because it preserved the entire application.

The web platform exposes this lifecycle through pagehide and pageshow. Their persisted property tells whether the browser intends to preserve or has restored the page. The modern guidance is also clear that unload is the wrong event for this world; pagehide is compatible with bfcache.

The userscript needs only small corrections when the frozen page returns. For example, gallery-reader synchronizes its search input from the restored URL on pagereveal:

window.addEventListener("pagereveal", () => {
    syncInputFromUrl(query);
});

The DOM is already correct. This handles the little pieces of derived UI that may need to agree with the history entry Safari just revealed.

I Do Not Control Eligibility

The difficult part is that bfcache belongs to both the browser and the host website. A normal application can adjust its response headers and lifecycle code. A userscript arrives after the response has already been received.

This produced a useful natural experiment:

Provider Response policy iPhone Back behavior
Hitomi Cache-Control: max-age=3600 bfcache restore
imhentai Cache-Control: no-store, no-cache, must-revalidate full reload

At first I suspected imhentai’s scripts: advertisements, tracking frames, beacons, timers, or a persistent connection. The userscript takeover wipes the original document, so it seemed possible that removing those scripts would make the page eligible again.

I instrumented the real iPhone lifecycle. Hitomi reported:

PAGEHIDE persisted=true
PAGESHOW persisted=true

imhentai returned through a new navigation:

PAGESHOW persisted=false
navigation type: back_forward

I then removed every original script and iframe during takeover. imhentai still reloaded. Chromium’s notRestoredReasons independently reported response-cache-control-no-store, and the live Safari behavior remained the same after all script-level suspects were gone.

The decisive instruction had arrived in the HTML response before my application existed. A userscript cannot repair that.

no-cache and no-store are often spoken about as if they were the same. They are not. Current bfcache guidance recommends no-cache or max-age=0 when a response may be stored but should be revalidated. no-store is for content that must not be stored and has historically made pages ineligible for bfcache. Using it broadly can throw away instant Back navigation for no useful reason.

I am lucky that Hitomi permits the fast path. But luck cannot be an architecture, so the userscript supports both.

The Reconstruction Path

When imhentai reloads on Back, gallery-reader starts from zero:

  1. the userscript recognizes the restored search URL;
  2. it builds the application shell;
  3. it fetches and renders the requested search page;
  4. it loads the scroll position saved for that pathname and query;
  5. only after the gallery exists does it restore the viewport.

That last ordering was recently enforced by the new agent-controlled iPhone test suite. The code originally started gallery rendering in the background and restored scroll immediately:

void paginate(query, page);
applyPendingScroll();

imhentai’s new document was not tall enough yet, so Safari clamped the restored position to zero. Hitomi hid the race because its previous document happened to have enough height.

The correct fallback is:

await paginate(query, page);
applyPendingScroll();

bfcache is therefore an optimization with application-level consequences, not the only correctness mechanism:

Fast path: Safari owns the complete frozen page.
Fallback:  the userscript rebuilds from URL and saved scroll state.

The fast path feels better because no reconstruction can be as exact as keeping the original thing alive. The fallback makes the application portable across sites that are less careful with caching.

Saving a Reading Position Is a Different Problem

bfcache preserves a page while it remains in memory. It does not give me a durable reader bookmark after reload, tab eviction, or reopening a URL.

Raw pixel offsets are a poor bookmark. Images can load at different times, viewport height can change, and manga-reader may append the next chapter to the same vertical document. scrollY = 18473 has no meaning outside one layout.

The semantic state is:

Which image is crossing the reading point?

Both readers use the middle of the viewport as that point. Their URLs encode the answer. A gallery URL ends with the image index. A manga URL contains the chapter and image. Reloading that URL rebuilds aspect-ratio skeletons and places the selected image at the viewport midpoint before full image loading completes.

This turns browser history into the persistence layer:

visual reading position
    → chapter/image identity
    → history.replaceState(...)
    → shareable and reloadable URL

replaceState is essential. Saving progress must update the current history entry, not add hundreds of entries that the user must traverse with Back.

Why scrollend

Saving on every scroll event is both noisy and conceptually wrong. While the finger is moving or momentum scrolling is active, there is no final reading position. The page may cross several images in one gesture.

scrollend provides the correct boundary: the scroll position has no more pending updates and the gesture has ended. WebKit added the event to Safari 26.2 and specifically lists saving scroll state as one of its uses.

gallery-reader then waits another 100 milliseconds:

window.addEventListener("scrollend", () => {
    setTimeout(() => {
        const image = document.elementFromPoint(
            window.innerWidth / 2,
            window.innerHeight / 2 + 1,
        ) as HTMLImageElement;

        const index = parseInt(image.id.split("#")[1]);
        history.replaceState(null, "", readerUrl(galleryId, index));
    }, 100);
});

scrollend is the standardized signal. The extra 100 ms is an empirical iPhone Safari settling window.

I arrived at that combination by fighting real reader failures. Reading the viewport synchronously at the event boundary was not consistently stable enough. Waiting 100 ms allowed WebKit’s final layout, compositing, and hit testing to agree about what occupied the midpoint.

This is not a claim that the web specification requires 100 ms. It is a rule earned by this application on physical Safari:

Let Safari declare the gesture finished, then give it one small beat before asking the rendered page what the user is looking at.

The same timing is used to save gallery-list scroll state. pagehide also saves immediately as a last chance when navigation begins.

Manga-Reader Extends the Same Rule

Manga-reader uses scrollend + 100ms for more than bookmarking. A scroll can cross from one chapter into the next in a continuous vertical strip.

After the settling window it:

  1. finds the last loaded image whose top has crossed the viewport midpoint;
  2. finds the chapter containing that image;
  3. replaces the URL with that chapter and image;
  4. updates the document title and provider tracking;
  5. if the visible chapter is the newest loaded one, appends its immediate newer chapter exactly once.

One event boundary coordinates persistence and incremental loading. The current URL always describes what is actually being read, even while the document grows below it.

Restoration suppresses this handler until the requested image has been placed. Otherwise the programmatic restoration scroll could immediately overwrite the very URL it is trying to honor.

The Architecture in Two Rules

These applications became much simpler once I stopped treating navigation and scrolling as continuous streams of state to mirror constantly.

For navigation:

Preserve the whole application when Safari offers bfcache; reconstruct from semantic state when the host website prevents it.

For reading:

After scrollend + 100ms, identify the content at the visual focal point and store its identity in the current URL.

bfcache owns short-term visual continuity. The URL owns durable semantic continuity. Local storage carries only the fallback state that cannot be expressed cleanly in the URL, such as a search page’s exact scroll offset.

Neither technique is obscure by itself. The useful discovery was how they fit a userscript application:

  • Safari can preserve an interface I injected into somebody else’s page.
  • A response header I cannot change can revoke that preservation.
  • A semantic URL can survive both outcomes.
  • A small settling delay can make the URL describe what the eye actually sees.

That is the base of the apps. They feel persistent not because they are native, but because they borrow Safari’s memory when it is available and leave themselves enough information to return when it is not.

References