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.
What a navigation is: the browser process drives it
"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.
- Initiate. Click, form submit, location assignment, meta refresh, or the address bar.
beforeunloadfires 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. - 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.
- 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.
- Commit. The response is handed to a renderer; the new document is created; the URL bar changes;
pagehideandunloadfire on the old document and it is torn down or stashed in the bfcache. - Load. Parsing, scripts, subresources, paint: parts 2 through 5 of this course.
DOMContentLoaded, thenload.
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.
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.
- Active: visible, focused. Passive: visible, unfocused. Hidden:
document.visibilityState === 'hidden': background tab, minimised window, screen off, app switched on mobile. - 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.
freezefires before,resumeafter. - 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. - Back/forward cached: frozen with the full document preserved for instant back navigation. Next chapter.
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.pagehide. Fires on navigation away, withevent.persistedtrue if the page is entering bfcache. Reliable on desktop; fires on mobile navigations. Use it to close connections.freeze/resume. Chromium only. Release resources in freeze; rehydrate in resume.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.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.
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.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.
- An
unloadlistener. The historical top blocker. Replace with pagehide. Cache-Control: no-storeon 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-cacherevalidates and does not block.- 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.
- Opener relationships across origins without COOP. Pages opened by window.open or that opened others.
- Certain embedded content: cross-origin frames with their own blockers; plugin content.
- Permissions and media: an active prompt, a playing media session, screen capture.
- Listen to
pageshowand, ifevent.persisted, treat it as a page view: refresh time-sensitive data, re-open connections, re-check auth state. - In
pagehide, close what you hold. Do not do anything slow. - Test with the DevTools Application → Back/forward cache panel. It navigates away and back and names each blocker.
- Watch for analytics distortion: a bfcache restore is a view without a load event; tools that count load undercount.
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.
pushState(state, unused, url)adds an entry;replaceStateedits the current one. URL must be same-origin. Thestateobject is structured-cloned and persists across reloads and bfcache; size is limited (Firefox: 16 MiB; keep it small).popstatefires on back and forward, with the entry's state. It does not fire on pushState. Hash changes also firehashchange.- 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. - Reload and deep links. The server must serve the app shell for every route the router handles, or a refresh on
/orders/42is a 404. The classic deployment mistake.
- Document title. Screen readers announce it; the tab shows it; history lists it.
- 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. - Announcement. If focus is not moved, an aria-live region saying "Navigated to Orders" is the fallback.
- 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.
- Cancellation. A fetch for route A should not update the DOM after the user moved to route B. AbortController per navigation.
- Error states. A real navigation shows an error page. A router that swallows a failed fetch shows a blank region.
- 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. - Built-in behaviours. Focus reset and scroll handling after the handler resolves, by default;
navigation.transitionfor loading state;e.signalis an AbortSignal that fires when the navigation is superseded. Most of the list above, for free. - Support. Chromium and Safari 26; Firefox in progress. Frameworks are adopting it underneath their routers.
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.
- Preconnect: DNS, TCP, TLS to the next origin. Cheap; worth doing for the two or three origins the next page will certainly need.
- Prefetch: fetch the next page's HTML (or a resource) into the HTTP cache at low priority.
<link rel="prefetch">, or Speculation Rules withprefetch. Saves TTFB on click. Privacy-preserving prefetch for cross-site uses a proxy in Chrome. - 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.
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.- A prerendered page runs. Its analytics fire, its timers run, its fetches hit the server. Check
document.prerenderingand defer side effects to theprerenderingchangeevent. Analytics must count activation, not load. - 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.
- Cross-origin prerender requires the target to opt in (
Supports-Loading-Mode: credentialed-prerender). - 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. - 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.
Iframes: a document inside a document
- 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).
- Same-origin:
frame.contentWindow.documentis reachable; the frames share the main thread; a slow script in one janks the other. - 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.
- Loading: an iframe is loaded after the parent's render-blocking resources;
loading="lazy"defers offscreen ones; it participates in the parent'sloadevent unless lazy.
sandbox: the restriction set from part 9.allow: Permissions Policy delegation (camera, payment, fullscreen, autoplay).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.referrerpolicy,loading,srcdoc,name(a target for links and forms).
- Third-party widget (payments, chat, maps): cross-origin iframe,
allowonly what it needs, resize via postMessage from inside, a timeout and fallback if it never loads. - Sandboxed user content: separate origin plus
sandbox; never allow-same-origin with allow-scripts on content from your own origin. - 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.
- Fenced frames: an iframe variant that cannot communicate with the embedder at all, for ads and privacy sandbox use cases.
Downloads, popups, and the things that are not navigations
- 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). - 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.
- 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. - Sandboxed frames need
allow-downloads. Cross-origin iframes need the Permissions Policy for downloads without a gesture.
window.openrequires 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.target="_blank"impliesrel="noopener"now; the new page gets no window.opener. Older browsers needed it explicit. With an opener, the new page could navigate yours (tabnabbing).- Popups and COOP: same-origin COOP severs the opener link; use
same-origin-allow-popupsfor OAuth flows that need to postMessage back, or redirect-based flows.
- Hash changes (
#section): same document, hashchange event, scroll to the fragment, one history entry. - Text fragments (
#:~:text=phrase): scroll to and highlight text; the fragment is not exposed to the page's script. location.replace: a navigation that does not add a history entry.location.reload: a navigation with a reload cache policy.- Form submission with
targetto an iframe or_blank: a navigation of that context, not this one.
Reading navigation in DevTools
| Where | Shows | Use it for |
|---|---|---|
| Network → Preserve log | Requests across navigations | Seeing redirects chains and the request that triggered a navigation; without it the log clears on commit |
| Network → document row → Timing | Queued, DNS, connect, TLS, waiting (TTFB), download | Where the blank second went |
| Performance → Timings track | navigationStart, FCP, LCP, DCL, load | Commit to paint |
| Application → Back/forward cache | Eligibility test with named blockers | The bfcache audit |
| Application → Speculative loads | Rules found, candidates, their status (ready, failed, why) | Debugging Speculation Rules; seeing a prerender activate |
| Application → Frames | Each frame's origin, process, sandbox flags, isolation state, opener | Iframe debugging; what process a frame is in |
| Sources → Event Listener Breakpoints → Load | Break on beforeunload, unload, pagehide, visibilitychange | Who added the unload listener |
Console → getEventListeners(window) | Listeners by type with their source | Finding the third-party unload handler blocking bfcache |
| chrome://discards | Tab lifecycle states; buttons to freeze and discard | Testing freeze and discard handling by hand |
- 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. - 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. - 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. - 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.