The Sun

Tom Bennet

Chrome prerendering is weird

Here’s something I learned at work recently: when you type in Chrome’s address bar, Chrome may load the top suggestion in the background before you press Enter. It isn’t just a DNS lookup or a fetch of the HTML. The page is fully prerendered: JavaScript runs, and anything it writes to localStorage stays written, even if you never visit the page. This isn’t a bug: it’s documented behaviour, designed to make navigation feel instant.

This caused a problem in the onboarding flow at Synthesia, where I work. It took me a long time to pin down because it only happened intermittently, and - for reasons I’ll explain - never when I was actively watching for it.

Context

When someone signs up for Synthesia, the flow they see depends on which landing page they came from. We use query parameters to tailor onboarding to a particular product or use case.

Those parameters only exist on the first URL loaded by the application. Once the user proceeds through the sign-up steps, they’re long gone. So on first load, the app takes a snapshot of the query string and saves it to localStorage. Later, when the account is created, it sends that snapshot to the backend, which picks the onboarding flow.

The snapshot is only taken once. If there’s already one in storage, the app leaves it alone. Once onboarding is completed, the snapshot is cleared.

What was happening

Some colleagues testing specific onboarding flows would occasionally get the wrong one. They’d open a fresh Incognito window, visit a landing page configured to send some specific parameters, sign up, and get the wrong journey. Then when I tried to reproduce the bug myself, it would work fine. This went on for months, and began to drive me crazy.

After a mammoth debugging session with Claude, I finally figured out what was happening:

  1. They open an Incognito window and start typing synt… into the address bar.
  2. Chrome suggests a page in the app they’ve visited before - like their recent videos - and highlights it.
  3. Chrome is confident enough that they’ll go there that it prerenders the page in the background.
  4. The app loads in that invisible page, grabs any parameters from the URL, and saves them in localStorage.
  5. The user carries on typing the URL they actually wanted - or modifies one of Chrome’s suggested URLs - with the right parameters.
  6. They hit Enter, land on the marketing site, and prepare to test the sign-up journey.
  7. They click through to sign up. The app loads with the right parameters, finds a snapshot already in localStorage, and leaves it alone.

From then on, every page in that Incognito session uses the wrong snapshot. Even though the app was opened with the correct parameters, Chrome had already loaded a different URL in the background and effectively poisoned localStorage with a different set of parameters.

Note that Incognito doesn’t protect you here. It starts with an empty localStorage, but that storage is shared by every tab in the Incognito session, and that includes Chrome’s invisible prerendered ones 🫠. And the address bar suggestions in Incognito are based on your normal browsing history, so Chrome still knows where you’re likely to go.

Why it was so hard to reproduce

Two things made this bug almost impossible to catch in the act:

  • It doesn’t happen with DevTools open. Chrome’s omnibox refuses to prerender a suggestion in any tab that has DevTools open, a check added back in 2019. Nothing shows in DevTools Speculative loads panel either. The moment I opened the Application tab to watch, the bug went away. Schrödinger’s localStorage 🐈
  • It doesn’t happen in a fresh browser profile. With no history there’s nothing to suggest, and nothing to prerender. Pasting a correct URL into the address bar skips the problem too.

Chrome also only prerenders a suggestion when it’s confident you’ll pick it, based on how often you’ve picked it before. You can see these scores at chrome://predictors (more on that here). The people who hit this bug were people who use Synthesia all day, i.e. my colleagues.

The fix

The fix was small. The app now checks document.prerendering before taking the snapshot. If the page is being prerendered, it waits for the prerenderingchange event, which fires when the user actually navigates to the page:

function whenActivated(callback) {
  if (document.prerendering) {
    document.addEventListener("prerenderingchange", callback, { once: true });
  } else {
    callback();
  }
}

whenActivated(saveSearchParamsSnapshot);

If the user never visits the page, the callback never runs, and nothing is saved.

Takeaways

Loading a page isn’t the same as someone visiting it. Chrome can prerender pages from the address bar, and sites can ask it to prerender links with the Speculation Rules API. If your code does something on page load that should only happen once, like saving state, logging an event or counting a view, check document.prerendering first. Analytics tools such as Google Analytics already do this.

On the server, prerender requests arrive with a Sec-Purpose: prefetch;prerender header, if you’d rather handle them there.

I hope this post will save someone else from journeying down this particular rabbit hole!