Part 10 · 8 chapters · ~55 min

Navigation And Lifecycle

A page begins with a navigation the browser process owns, lives through states most code ignores, and can be frozen, cached whole, or killed without a word. This part is the navigation sequence and where the blank second lives, the page lifecycle states and which events actually fire, the back/forward cache and its blockers, what a client-side router takes on when it takes over, prefetch and prerender via Speculation Rules, iframes, and the downloads and popups that are navigations that were not.

102

What a navigation is: the browser process drives it

the question

"The user clicks a link and the page goes blank for a second before anything appears. Where is that second?"

Navigation is owned by the browser process, not the renderer. The renderer asks for it; the browser process makes the request, reads the headers, decides which process should host the result, and then commits. Until commit, the old page is still the page. Until the new page's first paint, the user sees either the old page (if the browser keeps it) or blank.

the sequence
  1. Initiate. Click, form submit, location assignment, meta refresh, or the address bar. beforeunload fires on the current document; a returned value shows a prompt. Only user-initiated navigations show it, and only if the page has had user interaction.
  2. Request. The network service fetches. The service worker's fetch handler, if the URL is in a controlled scope, runs first. Preconnect and speculative DNS may already have happened from the renderer's hints.
  3. Headers. The browser inspects before reading the body. Redirects restart the fetch. Content-Disposition: attachment or a non-renderable type becomes a download and the old page stays. 204 No Content cancels the navigation entirely. COOP, Origin-Agent-Cluster, and the site determine process placement.
  4. Commit. The response is handed to a renderer; the new document is created; the URL bar changes; pagehide and unload fire on the old document and it is torn down or stashed in the bfcache.
  5. Load. Parsing, scripts, subresources, paint: parts 2 through 5 of this course. DOMContentLoaded, then load.
worked numbers
where the blank second lives:

  click → request sent       ~5 ms     (beforeunload, IPC)
  request → first byte       200–1500 ms  (DNS + TCP + TLS + server time: this is the second)
  first byte → commit       ~10 ms
  commit → first paint      50–500 ms   (parse, render-blocking CSS, first script)

Chrome paints the new page only when it has something to show, and keeps the old page visible until then if the old page was not already torn down. Safari shows white sooner.
the lens
Network panel: the first row with Type document, its Timing tab (Waiting for server response is TTFB). Performance panel: the trace begins at navigationStart; a long gap before the first "Parse HTML" is server time. The fix for the blank second is almost always on the server or the CDN, or in prefetching the navigation before the click (Speculation Rules, below).
A NAVIGATION, END TO END
from click to the old page being gone
swipe the figure sideways, or tap expand for full screen
1/6
click
The user clicks a link. The renderer tells the browser process: navigate to this URL. beforeunload fires on the old page; if a handler returns a value, the browser shows a prompt and may cancel right here.
103

Page lifecycle states, and which events you can trust

A page is not either loaded or gone. It can be visible, hidden, frozen, cached, or discarded, and the transitions between those are where state gets lost. The Page Lifecycle API names the states; the events that signal them are not the ones most code listens to.

the states
  1. Active: visible, focused. Passive: visible, unfocused. Hidden: document.visibilityState === 'hidden': background tab, minimised window, screen off, app switched on mobile.
  2. Frozen: hidden, and the browser has suspended the event loop. Timers, fetch callbacks, and rAF do not run. Chrome freezes after about five minutes hidden; sooner under memory pressure. freeze fires before, resume after.
  3. Discarded: the tab's renderer was killed. No event. On return, a fresh load with document.wasDiscarded === true. Mobile browsers discard aggressively; a user switching to their camera app and back may get a reload.
  4. Back/forward cached: frozen with the full document preserved for instant back navigation. Next chapter.
the events, by reliability
  1. visibilitychange. Fires on every transition to hidden, including tab switch, minimise, app switch, and before unload, freeze, and discard. The one to save state on.
  2. pagehide. Fires on navigation away, with event.persisted true if the page is entering bfcache. Reliable on desktop; fires on mobile navigations. Use it to close connections.
  3. freeze / resume. Chromium only. Release resources in freeze; rehydrate in resume.
  4. beforeunload. Only for "you have unsaved changes" prompts, added only while there are unsaved changes and removed after. Listening permanently blocks bfcache in some browsers.
  5. unload. Does not fire on mobile app switches, on frozen-then-discarded tabs, or on bfcache entries. Chrome is deprecating it. Do not use it.
worked numbers
sending data on the way out:

  navigator.sendBeacon('/analytics', blob)   // queued by the browser, survives the page; POST only; ~64 KB
  fetch(url, { method: 'POST', body, keepalive: true })  // same guarantee, any headers; 64 KB shared budget

a normal fetch in a visibilitychange handler is cancelled when the page is torn down. keepalive is the difference.
PAGE LIFECYCLE STATES
and the events that move between them
swipe the figure sideways, or tap expand for full screen
1/6
active · passive · hidden
Active: visible and has input focus. Passive: visible but not focused (another window on top, or a dialog). Hidden: not visible at all (tab in the background, minimised, another app). visibilitychange fires on the way into and out of hidden.
104

The back/forward cache

When the user presses back, the fastest possible response is to show the page exactly as they left it. The bfcache does that: the whole document, JS heap included, is frozen in memory on navigation away and thawed on return. Restores take tens of milliseconds and make no requests. Around a fifth of navigations on mobile are back or forward, so eligibility is a performance feature worth a day.

what makes a page ineligible
  1. An unload listener. The historical top blocker. Replace with pagehide.
  2. Cache-Control: no-store on the document. Chrome has carved out exceptions (no cookie change during the page's life) but still treats it as a blocker in many cases. If the concern is freshness, no-cache revalidates and does not block.
  3. Open connections at navigation time: WebSocket, WebRTC, an in-flight fetch with a body, an IndexedDB transaction, a Web Lock, a BroadcastChannel (Chrome). Close in pagehide, reopen in pageshow.
  4. Opener relationships across origins without COOP. Pages opened by window.open or that opened others.
  5. Certain embedded content: cross-origin frames with their own blockers; plugin content.
  6. Permissions and media: an active prompt, a playing media session, screen capture.
making it work
  1. Listen to pageshow and, if event.persisted, treat it as a page view: refresh time-sensitive data, re-open connections, re-check auth state.
  2. In pagehide, close what you hold. Do not do anything slow.
  3. Test with the DevTools Application → Back/forward cache panel. It navigates away and back and names each blocker.
  4. Watch for analytics distortion: a bfcache restore is a view without a load event; tools that count load undercount.
the sensitive-page question
"Should a banking page be in bfcache? Pressing back after logout shows the account page." The page is in memory, not on disk, and shows only to the same user at the same device. The right answer is to listen to pageshow and re-check the session, redirecting to login if it is gone, not to block bfcache with no-store, which punishes every navigation to fix one.
BACK/FORWARD CACHE ELIGIBILITY
what blocks it, and the test
swipe the figure sideways, or tap expand for full screen
1/6
eligible
Eligible: the page navigated away and nothing disqualified it. On back, pageshow fires with persisted true and the page appears in under 100 ms, with scroll position, form state and JS heap intact. No requests, no re-render.
105

Client-side routing: what the History API gives you and what it takes away

A single-page app replaces the browser's navigation with its own. pushState changes the URL without a request; the app swaps content. The browser's navigation did a dozen things besides fetching HTML, and the app now owns all of them.

History API facts
  1. pushState(state, unused, url) adds an entry; replaceState edits the current one. URL must be same-origin. The state object is structured-cloned and persists across reloads and bfcache; size is limited (Firefox: 16 MiB; keep it small).
  2. popstate fires on back and forward, with the entry's state. It does not fire on pushState. Hash changes also fire hashchange.
  3. Scroll restoration. The browser records scroll position per entry and restores it on popstate, often before the app has rendered the content, so it lands at the top. history.scrollRestoration = 'manual' and restore it yourself after render.
  4. Reload and deep links. The server must serve the app shell for every route the router handles, or a refresh on /orders/42 is a 404. The classic deployment mistake.
what a real navigation did that the router must now do
  1. Document title. Screen readers announce it; the tab shows it; history lists it.
  2. Focus. A real navigation puts focus at the top of the new document. A router that leaves focus on the clicked link, now removed from the DOM, drops keyboard and screen-reader users into nowhere. Move focus to the main heading (tabindex="-1") or the main region.
  3. Announcement. If focus is not moved, an aria-live region saying "Navigated to Orders" is the fallback.
  4. Loading state. The browser's spinner is gone. Show one after ~100 ms if the route's data is not ready; before that, nothing, or it flickers.
  5. Cancellation. A fetch for route A should not update the DOM after the user moved to route B. AbortController per navigation.
  6. Error states. A real navigation shows an error page. A router that swallows a failed fetch shows a blank region.
the Navigation API
  1. One event for everything. navigation.addEventListener('navigate', e => ...) fires for link clicks, form submissions, pushState, back/forward. e.intercept({ handler }) turns it into a same-document navigation whose completion is the handler's promise.
  2. Built-in behaviours. Focus reset and scroll handling after the handler resolves, by default; navigation.transition for loading state; e.signal is an AbortSignal that fires when the navigation is superseded. Most of the list above, for free.
  3. Support. Chromium and Safari 26; Firefox in progress. Frameworks are adopting it underneath their routers.
SPA NAVIGATION AND THE HISTORY API
what the browser does and does not do for you
swipe the figure sideways, or tap expand for full screen
1/6
pushState
history.pushState(state, "", "/orders/42"). The address bar updates, a session history entry is added, no request is made, no event fires in the page (popstate fires only on back/forward). The document is the same document.
106

Prefetch, prerender and Speculation Rules

The fastest navigation is the one that happened before the click. The browser offers three levels: prefetch the HTML, preconnect to the origin, or prerender the whole page in a hidden renderer so the click is an instant swap.

the levels
  1. Preconnect: DNS, TCP, TLS to the next origin. Cheap; worth doing for the two or three origins the next page will certainly need.
  2. Prefetch: fetch the next page's HTML (or a resource) into the HTTP cache at low priority. <link rel="prefetch">, or Speculation Rules with prefetch. Saves TTFB on click. Privacy-preserving prefetch for cross-site uses a proxy in Chrome.
  3. Prerender: load and render the next page in an invisible renderer process: HTML, CSS, JS executed, paint done. On click, the browser activates it: a navigation that takes a few milliseconds. Speculation Rules only; <link rel="prerender"> is dead.
worked numbers
Speculation Rules:

  <script type="speculationrules">
  {
    "prerender": [{ "where": { "href_matches": "/products/*" }, "eagerness": "moderate" }],
    "prefetch":  [{ "where": { "selector_matches": ".nav a" }, "eagerness": "conservative" }]
  }
  </script>

  eagerness:  immediate (now) · eager (viewport) · moderate (hover 200 ms) · conservative (pointerdown)
  also deliverable as a Speculation-Rules response header pointing at a JSON file.
the costs and rules
  1. A prerendered page runs. Its analytics fire, its timers run, its fetches hit the server. Check document.prerendering and defer side effects to the prerenderingchange event. Analytics must count activation, not load.
  2. Limits. Chrome allows a small number of prerenders per page (two immediate, more for moderate/conservative) and skips them on low-end devices, data saver, or low memory.
  3. Cross-origin prerender requires the target to opt in (Supports-Loading-Mode: credentialed-prerender).
  4. What breaks: pages that mutate server state on load, pages with per-view rate limits, A/B tests that assign on load. Gate on document.prerendering.
  5. The win: LCP of a prerendered activation is typically under 100 ms. For the two or three navigations a session reliably makes (product list → product, article → next article), this is the largest single improvement available.
107

Iframes: a document inside a document

what an iframe is
  1. A nested browsing context with its own document, its own realm (same agent if same-origin, else separate), its own history entries joined to the parent's session history (an iframe navigation adds an entry to the tab's back stack, which surprises everyone once).
  2. Same-origin: frame.contentWindow.document is reachable; the frames share the main thread; a slow script in one janks the other.
  3. Cross-origin: out of process (site isolation); only postMessage crosses; layout of the frame's contents is invisible to the parent, so a parent cannot size to the frame's content without the frame reporting it.
  4. Loading: an iframe is loaded after the parent's render-blocking resources; loading="lazy" defers offscreen ones; it participates in the parent's load event unless lazy.
attributes that matter
  1. sandbox: the restriction set from part 9.
  2. allow: Permissions Policy delegation (camera, payment, fullscreen, autoplay).
  3. credentialless: the frame loads in a fresh, empty cookie jar and storage partition; the way to embed cross-origin content under COEP without the embedded site's cooperation.
  4. referrerpolicy, loading, srcdoc, name (a target for links and forms).
the embed patterns
  1. Third-party widget (payments, chat, maps): cross-origin iframe, allow only what it needs, resize via postMessage from inside, a timeout and fallback if it never loads.
  2. Sandboxed user content: separate origin plus sandbox; never allow-same-origin with allow-scripts on content from your own origin.
  3. Micro-frontends by iframe: workable, isolated, slow to start (a full document each) and awkward to style; a last resort over module federation or islands.
  4. Fenced frames: an iframe variant that cannot communicate with the embedder at all, for ads and privacy sandbox use cases.
108

Downloads, popups, and the things that are not navigations

downloads
  1. Triggered by a Content-Disposition: attachment header, a type the browser cannot render, or an <a download> attribute (same-origin or blob/data URLs only).
  2. The page stays. A download is a navigation that was cancelled at the headers step, so the current page is untouched. This is why "click to download" links work without a target.
  3. Generated files: URL.createObjectURL(blob) plus an anchor with download, then revoke the URL. Or the File System Access API's save picker for a proper save-as.
  4. Sandboxed frames need allow-downloads. Cross-origin iframes need the Permissions Policy for downloads without a gesture.
popups and new windows
  1. window.open requires transient user activation or it is blocked. Activation lasts about five seconds and is consumed by one popup. An await before window.open loses it: open the window synchronously, then set its location.
  2. target="_blank" implies rel="noopener" now; the new page gets no window.opener. Older browsers needed it explicit. With an opener, the new page could navigate yours (tabnabbing).
  3. Popups and COOP: same-origin COOP severs the opener link; use same-origin-allow-popups for OAuth flows that need to postMessage back, or redirect-based flows.
also not navigations
  1. Hash changes (#section): same document, hashchange event, scroll to the fragment, one history entry.
  2. Text fragments (#:~:text=phrase): scroll to and highlight text; the fragment is not exposed to the page's script.
  3. location.replace: a navigation that does not add a history entry. location.reload: a navigation with a reload cache policy.
  4. Form submission with target to an iframe or _blank: a navigation of that context, not this one.
109

Reading navigation in DevTools

WhereShowsUse it for
Network → Preserve logRequests across navigationsSeeing redirects chains and the request that triggered a navigation; without it the log clears on commit
Network → document row → TimingQueued, DNS, connect, TLS, waiting (TTFB), downloadWhere the blank second went
Performance → Timings tracknavigationStart, FCP, LCP, DCL, loadCommit to paint
Application → Back/forward cacheEligibility test with named blockersThe bfcache audit
Application → Speculative loadsRules found, candidates, their status (ready, failed, why)Debugging Speculation Rules; seeing a prerender activate
Application → FramesEach frame's origin, process, sandbox flags, isolation state, openerIframe debugging; what process a frame is in
Sources → Event Listener Breakpoints → LoadBreak on beforeunload, unload, pagehide, visibilitychangeWho added the unload listener
Console → getEventListeners(window)Listeners by type with their sourceFinding the third-party unload handler blocking bfcache
chrome://discardsTab lifecycle states; buttons to freeze and discardTesting freeze and discard handling by hand
go to the lab
  1. Route /nav/slow-server: a link to a page with a 1.5 s TTFB. Record the Network timing; compare with the same link under a Speculation Rules prerender on hover.
  2. Route /nav/bfcache: a page with an unload listener and a WebSocket. Run the bfcache test, read the blockers, apply the fixes one by one until it passes.
  3. Route /nav/lifecycle-log: logs every lifecycle event to a persistent store. Switch tabs, minimise, use chrome://discards to freeze and discard, come back. Read which events fired on which path.
  4. Route /nav/spa-router: a router that does pushState and nothing else. Navigate with a screen reader on; then enable the title, focus, and scroll fixes one at a time.