By default, what does a browser do to the scroll position when the user presses Back, and what does setting history.scrollRestoration to 'manual' change for a single-page app?
answer
- the browser restores too early
- offset clamps against the old document
- hand the job to the router
- save on leave, restore after layout
- instant, not smooth
basics
~20 sBy default the browser stores each history entry's scroll offset and restores it during traversal — too early for a single-page app that renders only after popstate, so the page is still short and the offset is clamped. Setting history.scrollRestoration = 'manual' hands that job to your router.
solid answer
~50 sWith the default value `'auto'`, the browser records the scroll offset on each session history entry and reapplies it when the user traverses back to that entry. That works for multi-page sites because the restored document already has its full height. In a single-page app the traversal fires `popstate` while the old view is still on screen; the browser restores the offset against the *current* document height, and then your router swaps in a short new view, so the position is clamped to the top or lands somewhere arbitrary — the familiar "Back loses my place in the feed" bug. Setting `history.scrollRestoration = 'manual'` disables the automatic restore. The router then owns it: save `window.scrollY` against the outgoing entry before navigating away, and after the new view has rendered and laid out, scroll back to the saved offset. The property is per history entry, so set it at startup and re-assert it as you create entries; read it back to feature-detect.
code
javascript · 20 linesif ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
const offsets = new Map();
function navigate(path) {
offsets.set(history.state?.key, window.scrollY);
history.pushState({ key: crypto.randomUUID(), path }, '', path);
render(path);
window.scrollTo({ top: 0, behavior: 'instant' });
}
window.addEventListener('popstate', (event) => {
render(location.pathname);
const y = offsets.get(event.state?.key) ?? 0;
requestAnimationFrame(() => {
window.scrollTo({ top: y, behavior: 'instant' });
});
});go deeper
Know that the browser normally remembers scroll position per history entry, and that history.scrollRestoration accepts 'auto' or 'manual' to turn that automatic behaviour off.
Explain the ordering problem: the restore happens before popstate renders the new view, so the offset is clamped against a document that is still the wrong height, and describe the save-on-leave, restore-after-render pattern.
Handle the real cases — async content that arrives after the restore, virtualized lists where pixels are meaningless, nested scroll containers, and where to persist the offsets so they survive a reload without flooding history writes.
Decide whether scroll restoration is the router's responsibility or each view's, and set the convention: who owns the scroller, what identity a restored position is keyed by, and how the app degrades when content height cannot be reproduced.
## What the default does Every session history entry carries a scroll position alongside its URL and state. With `history.scrollRestoration === 'auto'` — the default — the browser saves the offset as you leave an entry and reapplies it when you traverse back. On a classic multi-page site this is invisible and correct: the document is reconstructed to its full height before the restore, so the offset is meaningful. ## Why it misfires in a single-page app The SPA sequence is out of order: 1. The user presses Back. 2. The browser traverses to the previous entry and tries to restore its scroll offset — but the **document has not changed**. The DOM on screen is still the short detail page. 3. `popstate` fires. 4. Your router renders the long feed. At step 2 the requested offset (say 4200px) exceeds the current scrollable height, so it is clamped — usually to 0. By the time the tall content exists at step 4, the browser considers the restore done. The user lands at the top of a feed they had scrolled halfway through, which is one of the most-reported SPA complaints. ## Taking over ```js if ('scrollRestoration' in history) { history.scrollRestoration = 'manual'; } ``` The property is an attribute of the current session history entry, not a global switch, so routers set it during startup and re-assert it as they create entries. Reading it back is also the feature detection — an engine without support simply will not have the property. Then the router does the bookkeeping. Save on the way out, restore after render: ```js const offsets = new Map(); function navigate(path) { offsets.set(history.state.key, window.scrollY); // before leaving history.pushState({ key: crypto.randomUUID(), path }, '', path); render(); window.scrollTo(0, 0); // new views start at the top } window.addEventListener('popstate', (e) => { render(location.pathname); const y = offsets.get(e.state?.key) ?? 0; requestAnimationFrame(() => window.scrollTo({ top: y, behavior: 'instant' })); }); ``` Two details carry the whole thing. **Restore after layout**, not immediately: `scrollTo` against a document that has not yet grown to full height clamps exactly as the browser's own attempt did, so schedule it after the new content is in the DOM and measurable. **Use `'instant'`**, not smooth — a Back press should feel like arriving, not like an animation, and a smooth scroll can be interrupted by the user. ## The hard cases **Asynchronous content.** If the restored view fetches its items, the correct height does not exist for hundreds of milliseconds. A single post-render `scrollTo` is not enough. Options: reserve the height by caching the previous render or by keeping placeholder skeletons of the right size; or retry the restore once the list reports its final height, for example from a `ResizeObserver` on the scroll container, and stop as soon as the user scrolls themselves so you never fight their input. **Virtualized lists.** The scroll offset is meaningless because item heights are estimated. Persist the first visible item's identifier instead of pixels, and have the list scroll that item into view on restore. **Nested scroll containers.** `window.scrollY` covers only the document scroller. A pane with `overflow: auto` has its own `scrollTop`, which nothing restores for you; record and restore each container you care about. **Where to keep the offsets.** A module-level `Map` keyed by the entry's state key is simple but dies on reload. Putting the offset into the history state itself with `replaceState` makes it survive a reload and session restore, at the cost of a history write on every scroll-away — debounce it. Cross-tab persistence in `sessionStorage` is a third option, keyed the same way. ## Whose job is this anyway Every mature router — and the browser's own newer navigation machinery — ships some version of this. If you are using one, the interview answer is knowing *what it is doing on your behalf* and where its assumptions break: async content, virtualization, and nested scrollers are precisely where a framework's built-in restoration stops being enough and you have to reach for the primitives above.
- You set scrollRestoration to 'manual' and call scrollTo inside the popstate handler, but Back still lands near the top. Why?The content is probably not tall enough yet. If the restored view fetches its data, the document height at scrollTo time is still small and the offset is clamped exactly as the browser's own restore was. Restore after the content has laid out — retry when the container reports its real height, and abandon the retry as soon as the user scrolls.
- How would you restore position in a virtualized list where item heights are estimated?Do not persist pixels. Record the identifier of the first visible item, and on restore let the list scroll that item into view once it is materialized. Pixel offsets in a virtualized list refer to a layout that is recomputed from estimates and will not reproduce the same position.
- What are the tradeoffs of storing scroll offsets in history state instead of a module-level Map?History state is persisted with the entry, so offsets survive reload and session restore rather than dying with the page. The cost is a replaceState write each time you leave a view — debounce it — plus the size and staleness discipline that applies to anything you put on a history entry.
saying these in an interview costs you the question
- Thinks 'manual' means the page cannot be scrolled
- Restores the offset before the new view has laid out
- Uses smooth scrolling to restore a Back navigation
- Assumes window.scrollY covers nested scroll containers
- Believes scrollRestoration is a single global setting