Scroll Restoration
The browser does this well and single-page applications break it: history.scrollRestoration, restoring on back but not on a forward navigation, and the async data problem that puts you in the wrong place.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Why does back land in the wrong place in my single-page application when the same site got it right as a set of ordinary documents?
Someone scrolls two thirds of the way down a long list, opens the eleventh result, reads it, and presses back. They expect to be looking at the eleventh result again, not at the top of the list.
Scroll to the top on every navigation. It is what a new page does, it is one line, and back is the browser's job anyway.
Back returns to the list at the top. The user scrolls down again, finds their place, opens the twelfth result, presses back, and does it again. On a long list this is the single most-complained-about behaviour in a client-rendered application.
- Back returns to the list at the top. The user scrolls down again, finds their place, opens the twelfth result, presses back, and does it again. On a long list this is the single most-complained-about behaviour in a client-rendered application.
- Restoring the saved offset works locally and fails in production, because in production the data has not arrived yet: the document is one screen tall, the browser clamps the requested offset to the maximum, and the user lands at the top of a page that is about to become four screens long.
- The application scrolls an inner
divrather than the document, so the browser never had a scroll position to save. Nothing is restored on back, on reload, or ever, andhistory.scrollRestorationmakes no difference at all. - Restoration fires during a smooth-scroll transition and animates from the top of the page to the target, which looks like a bug and, for a user who has asked for reduced motion, is one (Contrast, Colour and Motion).
- The list is virtualised, so a pixel offset means nothing: the same offset points at a different item once the estimated row heights are replaced with measured ones (List Virtualization).
- A forward navigation to a new route inherits the old page's scroll position, so the article opens halfway down.
What is actually happening
In the browser, not in the framework.
- The browser stores a scroll position on each history entry, alongside the URL and the state object. It is saved as you leave an entry and applied when you return to it.
history.scrollRestorationis a property with two values.'auto', the default, means the browser restores the saved position on traversal.'manual'means it will not, and the job is yours. The setting lives on the current history entry, so it is set once at startup and travels with the session.- For a forward navigation, the browser does not restore: it scrolls to the top, or to the element matching the URL fragment if there is one. Two different behaviours for two different intents, and both have to be re-implemented.
- In a single-page application the browser's restore happens at the wrong moment. When
popstatefires, the DOM is still the outgoing view; the router then replaces it. Whatever the browser did with the scroller has already been invalidated by the time the destination exists (History and Navigation). - Scrolling is clamped.
window.scrollTo(0, 3400)on a document that is currently one viewport tall does not scroll to 3400 and wait — it scrolls as far as it can, which is nowhere, and forgets the request. This is why restoring before the content has height is not a delay but a silent failure. - Only scrolling containers have positions, and the browser only tracks the document scroller for history. An application shell with
overflow: autoon a main panel has taken the scroller out of the browser's hands (Normal Flow, Overflow and Margin Collapsing). - Scroll anchoring is a separate browser feature that keeps the visual position stable when content above the viewport changes size. It mitigates late-arriving content; it does not restore anything, and
overflow-anchor: nonedisables it (Visual Stability). - A page restored from the back/forward cache is restored whole — DOM, script state and scroll together. Your restore logic must not run again on top of it (Long-Lived Clients and Version Skew).
What this makes the browser do
And which of it is avoidable.
- Saving and applying a scroll offset is close to free. The expense is entirely in what the restore forces: a scroll on a tall document invalidates the visible area and requires paint and composite for content that was not on screen (Compositing Layers).
- Restoring into a virtualised list is more expensive than it looks: the list must render the items around the target offset, which is a mount, a style pass and a layout for content the user has not asked for yet.
- A retry loop that polls
scrollHeightuntil it reaches the target is main-thread work on every frame it runs. Bound it, and prefer an observer over a poll (The Frame Budget). - Avoidable work: restoring, then having late content push the page around, then restoring again. Reserving space for known-size content removes both passes (Intrinsic Sizing and the Automatic Minimum).
What the browser does, and exactly where it stops
It is worth being precise about how good the default is, because the instinct after a bad experience is to blame the browser. For ordinary documents, scroll restoration is close to perfect: the position is stored per entry, applied on traversal, reset on a new navigation, and it survives reload and the back/forward cache. Nobody implements it and nobody complains about it.
Every step below has a client-side navigation failure attached, and they all have the same root: the browser saves and applies positions against a document it believes it owns. A router that replaces the document's content after the fact invalidates each step in turn.
- 1Save on leave
Records the document scroller's offset into the outgoing history entry.
fails by Only the document scroller is tracked. An app shell with
overflow: autoon a panel has no saved position, ever. - 2Reset on a new navigation
Scrolls to the top for a fresh document, or to the fragment if the URL has one.
fails by No new document, so nothing resets. The next route opens at the previous route's offset.
- 3Apply on traversal
On back or forward, applies the saved offset to the restored document.
fails by In a single-page application the offset is applied to the outgoing view, which the router then replaces.
- 4Clamp to the maximum
Refuses to scroll past the end of the document. Sensible, and unforgiving.
fails by Restoring before content has height silently becomes "scroll to the top" with no error and no retry.
- 5Anchor during load
Scroll anchoring keeps the visual position when content above the viewport changes size.
fails by It mitigates growth; it does not restore. Disabling it to make restores deterministic loses the mitigation everywhere else (Visual Stability).
- 6Restore from bfcache
Returns the whole document, including scroll, as it was — no re-execution.
fails by Router restore logic runs again on
pageshowand moves the user away from where the browser correctly put them. - 7Restore on reload
Reapplies the saved offset after the document has laid out.
fails by A client-rendered page is one viewport tall at that moment, so the offset clamps and the user reloads to the top (Client-Side Rendering).
Six of the seven failures are the same failure: the browser acted on a document whose real content had not arrived.
Restore too early and you land in the wrong place
This is the mechanism behind the bug that only happens in production. Follow the ordering: the traversal fires, the router swaps the view, the shell renders with an empty outlet, and the restore runs against a document that is currently one screen tall. The request for offset 3400 is clamped to roughly zero and discarded. Then the data arrives, the list renders, the document becomes four screens tall — and the user is at the top of it.
The timeline below is a shape rather than a measurement; what transfers is the ordering, not any duration. On a warm cache the data bar starts and ends before the render, the document already has its full height when the restore runs, and the same code appears to work perfectly.
- popstate (Back pressed) — The traversal has already happened. The router is being told, not asked.
- Shell renders, outlet empty — Document height is now about one viewport.
- Data request for the list — Nothing is stopping the restore from running while this is in flight.
- Naive restore: scrollTo(0, target) — The failure. Clamped to the current maximum, which is roughly zero, and then forgotten.
- Layout — document reaches full height — Now the target offset is reachable. The user is at the top.
- Correct restore: fires here — Gated on
scrollHeight >= target + viewport, checked on commit and on resize, with a timeout that gives up at the top.
The two restore bars are the lesson. The first one runs when the code is ready; the second runs when the document is. Reserving space with a correctly sized skeleton collapses the gap between them to nothing, which is why it is the better fix.
Four navigations, four different answers
The reason "scroll to the top on navigation" is such a persistent bug is that it is right one quarter of the time. Scroll behaviour is a function of *why* the URL changed, and a router that does not distinguish the reasons cannot get more than one case right.
Read the last column as the symptom you will receive in a bug report. None of these arrive described as a scroll problem; they arrive as "back is broken", "it jumps", or "the anchor links stopped working".
- One function, one switch on the navigation type. Four separate call sites is how a case goes missing.
- Save on leave, restore on arrive. Saving on arrival records the position of a view the user has already gone past.
- Key by history entry, not by URL — the same URL can sit at several depths in one stack with different positions (History and Navigation).
| Why the URL changed | What a document does | What the router must do | Symptom if it does nothing |
|---|---|---|---|
| Link to a new route | Scroll to the top of the new document | Scroll to top after the new view commits | The article opens two thirds of the way down |
| Back or Forward | Restore the offset saved on that entry | Restore the saved offset once the content has height | Back lands at the top of the list, every time |
Fragment link (#pricing) | Scroll the matching element into view | Scroll it into view after render, and move focus to it | Nothing happens, because the element mounted after the browser looked (Keyboard Operability) |
| A filter or sort applied in place | Not a navigation at all | Leave the scroll position alone; replace the URL | The list jumps to the top on every keystroke (URL Parameters) |
| Reload | Restore the offset after layout | Let the browser try, then re-apply once data has arrived | Reloading a deep position always returns to the top (Client-Side Rendering) |
| Restore from bfcache | Restore everything, scroll included | Nothing — detect pageshow with persisted and stand down | A correct position is overwritten by a stale saved one |
How to build it
Most important first.
- Set
history.scrollRestoration = 'manual'once, at startup, and take the whole job. Half-manual — letting the browser try and correcting afterwards — produces a visible jump. - Save the offset when you leave an entry, not when you arrive at the next one. The moment to record it is the navigation start, while the outgoing view is still mounted and still the right height.
- Store the offset on the history entry via
replaceState, or in a map keyed by a stable per-entry key. A plain URL is not a key: the same URL can appear at three points in the stack with three different positions. - Restore after the destination has enough height, not after render. The condition is
scrollHeight >= target + viewportHeight, checked once on commit and then on a bounded set of retries or aResizeObserver, with a timeout that gives up gracefully at the top. - Better still, remove the race: reserve the space. A skeleton with the same dimensions as the real content makes the document the right height before the data arrives, and the restore becomes a single unconditional call (Visual Stability).
- Distinguish the three cases explicitly — traversal restores, new navigation goes to top, fragment navigation scrolls the element into view — and route them through one function so no case is silently missing.
- In a virtualised list, do not restore a pixel offset. Save the id of the first visible item and its offset within the viewport, and scroll that item back into place (List Virtualization).
- If you own the scroller, own the restore. Any element with
overflow: autothat persists across navigations needs its own save-and-restore, because the browser is not tracking it.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Scroll position is not focus position. Restoring the pixels without restoring focus leaves a keyboard user at the top of the document while the visible viewport is two thirds down — they tab into content they cannot see (Focus Management).
- The strongest version of restoration on back is to return focus to the element the user left from — the link they activated — so keyboard and screen-reader users get the same continuity that scroll restoration gives sighted mouse users (Accessible Component Patterns).
- Restore with an instant jump, not a smooth animation, and honour
prefers-reduced-motionfor any scroll animation you do add. A long animated scroll is a vestibular trigger and an interruption (Contrast, Colour and Motion). - Do not scroll content under a screen-reader user without cause. Programmatic scrolling changes what a magnifier shows without changing what the reader is on, and the two silently diverge.
- Fragment navigation must move focus as well as scroll. Scrolling
#pricinginto view without focusing it means the nextTabstarts from wherever focus actually was, usually the top (Keyboard Operability).
What can go wrong
- Restoring too early, which clamps to the current maximum and lands the user at the top. This is the dominant failure and it never reproduces on a warm cache.
- Restoring too late, so the user sees the top of the page for a moment and is then jumped down — worse than not restoring, because the content moved under a reader.
- Restoring on a forward navigation, so a fresh page opens scrolled into its middle.
- Keying saved positions by URL rather than by history entry, so revisiting the same list from two different depths restores the wrong one.
- The mitigation fails too: an unbounded retry loop that waits for the document to reach a height it will never reach — because the content is shorter than it was, or the request failed — leaves a scroll handler running for the life of the tab.
- Double restoration after a bfcache restore, where the browser has already put everything back and the router puts it back again from a stale saved value.
- Fighting scroll anchoring: disabling
overflow-anchorto make a restore deterministic, and losing the browser's mitigation for every other late-arriving element on the page.
- The restore and the data. The restore usually wins, which is precisely the failure (The Life of a Fetch).
- The restore and a late-loading image or font that changes the height of content above the target, moving the anchor after you have already landed on it (Images and Fonts).
- Two rapid traversals, where the save for the second entry runs after the restore for the first and overwrites it with the wrong offset.
- A bfcache restore and the router's own restore, both targeting the same scroller in the same frame.
- A saved scroll offset is not sensitive on its own, but a saved *anchor* can be: storing "the user was looking at record 4471" in a history entry or in
sessionStorageputs an identifier somewhere it can outlive the session (localStorage and sessionStorage). - Scroll position is a side channel for content presence. A page whose height changes depending on whether a resource exists can leak that fact to a script that measures it, which is one reason cross-origin content is not measurable (The Same-Origin Policy).
- Restoration logic must tolerate a hostile state object: an entry's stored offset can be edited by any script on the page, so treat it as a number to validate rather than a number to trust.
- Scroll-driven loading is a request amplifier. A restore that lands deep in an infinite list can trigger several page fetches at once; rate-limit on the client and expect the server to as well (The Rate-Limit Contract in API Design).
- "
scrollRestoration = 'manual'fixes scroll restoration." It turns the browser's attempt off. Everything after that is code you have not written yet. - "Scroll to the top on navigation." Correct for a new navigation, wrong for a traversal, wrong for a fragment link, and wrong for a filter change on a list the user is already reading. It is four cases, not one.
- "The browser restores scroll on back, so a single-page application does too." The browser restores against the document it saved. Your router replaces that document's content afterwards.
- "It works, I tested it." On a warm cache with data in memory, the restore runs against a full-height document. The bug lives entirely in the cold path (Stale-While-Revalidate).
- "Scroll restoration is a polish task." It is the difference between a list being usable and a user re-scrolling on every single back press, which is the most repeated interaction in most content applications.
Measuring it, and what changes in the field
- The reproducible test needs a cold cache and a throttled network. On a warm cache the data is present before the restore runs and every implementation looks correct (Debugging the Network).
- Log the target offset and the document height at the moment of the restore. Almost every failure is visible as "asked for 3400,
scrollHeightwas 900". - Visual stability metrics in the field catch the second-order problem — content moving after the restore — which is what users describe as "it jumps" (Visual Stability).
- Record the ratio of restores that hit their target to restores that gave up at the top. It is the only number that tells you whether this works for real users rather than for you (Real User Monitoring).
- On a slow network, the gap between render and data is wide, which is exactly the window in which a naive restore fails. This is the condition to test in, not an edge case.
- On a slow device, layout of a long list is itself slow, so the height arrives in stages and a single height check can pass before the final layout (Layout Thrashing).
- With a very large list, the honest answer is that pixel offsets stop meaning anything and only an item anchor is stable (List Virtualization).
- On a page whose content changed since the user left — a feed with new items on top — restoring the old offset is wrong even when it works, because the content at that offset is different. Anchor to an item, or accept the top.
- On mobile, the dynamic browser chrome changes the viewport height as the user scrolls, so a restored offset can produce a slightly different visual position than the one that was saved (The Viewport and Device Pixels).
- Manual restoration is correct and is now permanently your code: every new async data source, every new scroller and every layout change is a new opportunity to break it.
- Reserving space with skeletons removes the race and requires you to know the content's size in advance, which is a design constraint on every list you build (Loading, Error, Empty — The States You Did Not Render).
- Anchoring to an item instead of an offset is robust to changing content and requires a stable id per row plus bookkeeping the offset approach does not need.
- A retry loop is simple and burns frames; a
ResizeObserveris precise and adds a subscription you must tear down. Both need a timeout, because some pages never reach the height they used to have.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALPer-entry scroll storage, the
history.scrollRestorationproperty, scroll-to-top on forward navigation and clamping to the current maximum offset are specified behaviour and are consistent across Blink, Gecko and WebKit. - BROWSER-SPECIFICThe heuristics around the edges differ: when exactly a browser considers layout stable enough to apply a saved offset, whether scroll anchoring is enabled for a given scroller, and how mobile browser chrome changes the visual viewport during a scroll all vary by browser and by version.
- FRAMEWORK-SPECIFICReact Router ships an opt-in
ScrollRestorationcomponent; Next.js and SvelteKit handle the common cases in their own routers with their own timing. All of them restore against the document scroller, so an application shell that scrolls an inner element gets nothing from any of them.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — restoration is only testable under a cold cache and a throttled network, which makes it a good example of a behaviour whose test environment is the whole test.