When building a micro-frontend app, I ran into a deceptively simple requirement: after a user triggers an external redirect (OAuth, payment flow, etc.) and returns to the page, scroll back to the element they were interacting with.
My first instinct was "just save the position and scroll back." That turned out to be wrong.
The real insight: this is a timing problem, not a scroll problem. Three things must happen in sequence, each at the correct moment in the browser's rendering pipeline. Once I mapped this out, every technical decision fell into place.
Part 1: The Map — Browser Event Loop
Before any solution, let's build the map. One iteration of the browser event loop runs in four phases:
Where each API lands in this loop:
| API | Phase |
|---|---|
useEffect callback |
① Macrotask |
MutationObserver callback |
② Microtask (after DOM change, before render) |
requestAnimationFrame |
③ Render phase (before Paint) |
setTimeout |
④ Next macrotask (after render completes) |
Keep this table in mind — every decision below maps back to it.
Part 2: Three Things, In Order
The scroll restoration flow breaks down into three sequential steps:
State survives redirect → Element appears in DOM → Element is painted → Scroll
Each step has a common pitfall.
Step 1: State Survives the Redirect
The redirect may open an external page in a new tab, then redirect back — breaking session-scoped storage. Options:
-
sessionStorage: isolated per tab, data is gone after a cross-tab redirect ❌ - URL parameters (encode
focusIdinto redirect URL): elegant, but external services (OAuth, payment) rarely support custom parameter passthrough ❌ - Redux / Zustand: store resets on page reload or navigation ❌
Only localStorage survives cross-tab, cross-page navigation.
// Illustrative — persist focus state before navigating away
interface FocusState {
focusId: string; // unique identifier of the target element
}
// save before jump
localStorage.setItem(STORAGE_KEY, JSON.stringify({ focusId }));
// read after redirect
const raw = localStorage.getItem(STORAGE_KEY);
Step 2: Wait for the Element to Appear in DOM
After the redirect, the page starts rendering. But if the target element lives deep in a virtual list and hasn't scrolled into the viewport, it won't be in the DOM yet. A querySelector in useEffect (which runs as a macrotask) may return null.
Options for waiting:
-
setIntervalpolling: simple but wasteful — runs regardless of DOM activity, timing is random, may land mid-render ❌ -
IntersectionObserver: detects whether an element is in the viewport — the wrong direction for our use case ❌ -
MutationObserver: event-driven, zero polling overhead, fires only on DOM changes — and crucially, its callback runs as a microtask
Why does microtask timing matter? Here's what happens when a virtual list renders a new node:
MutationObserver's callback fires after appendChild but before the next paint — the earliest possible moment to detect the new node. setInterval can't guarantee landing in this precise window.
Important: at this point, the node exists in the DOM but has not been painted yet. Those are two different things, and the gap between them is exactly what Step 3 handles.
Step 3: Wait for the Element to Be Painted
This is the easiest step to get wrong.
Whether you find the element in useEffect directly (fast path) or via MutationObserver (slow path), at the moment of discovery the element is in the DOM but not yet painted. Calling scrollIntoView immediately forces the browser to synchronously compute Layout before it's finished — a forced reflow. The result is inconsistent and potentially inaccurate positioning.
The candidates for "wait until painted":
-
setTimeout(0): enters the next macrotask, but its scheduling is independent from render scheduling — may fire before or after Frame N's Paint ⚠️ -
Single
requestAnimationFrame: fires in Frame N's render phase, after Style and Layout, but before Paint ❌ -
requestIdleCallback: waits for browser idle time — could be hundreds of milliseconds on a busy page ❌ -
Double
requestAnimationFrame: rAF ① (in Frame N's render phase) registers rAF ②; rAF ② fires at the start of Frame N+1 — by then, Frame N's Paint is guaranteed complete ✅
The key: double RAF isn't about "waiting two frames." It's about using rAF's registration mechanism to precisely cross one frame boundary, ensuring the previous frame's Paint is complete.
Part 3: Double RAF — The Right Way to Wait for Paint
requestAnimationFrame runs in the render phase, specifically before Paint. A single rAF isn't enough.
// Illustrative — double RAF ensures previous frame's Paint is complete
function scrollWhenReady(element: Element) {
requestAnimationFrame(() => {
// rAF ①: current frame's render phase
// Style + Layout done, Paint NOT started yet
requestAnimationFrame(() => {
// rAF ②: next frame begins
// Previous frame's Paint is complete — element is on screen
setTimeout(() => {
// Extra 50ms buffer for complex layout edge cases
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 50);
});
});
}
rAF ① runs in Frame N's render phase and registers rAF ②. rAF ② runs at the start of Frame N+1 — at which point Frame N has finished Paint. scrollIntoView now operates on the real, on-screen layout, no forced reflow, stable behavior.
scrollWhenReady is the core of the whole solution. No matter which path finds the element, this is always the final step.
Part 4: Fast Path and Slow Path
With scrollWhenReady in place, the find logic is straightforward: is the element in the DOM? If not, wait. If yes, call scrollWhenReady.
Fast Path — element already in DOM:
// Illustrative — fast path: element already in DOM
useEffect(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const { focusId } = JSON.parse(raw);
const element = document.querySelector(`[data-id="${focusId}"]`);
if (element) {
scrollWhenReady(element); // found — go straight to double RAF
}
}, []);
Slow Path — element not yet rendered, use MutationObserver:
// Illustrative — slow path: element not yet in DOM
useEffect(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const { focusId } = JSON.parse(raw);
// First attempt
const existing = document.querySelector(`[data-id="${focusId}"]`);
if (existing) {
scrollWhenReady(existing);
return;
}
// Not found — observe DOM mutations
const timeoutId = setTimeout(() => observer.disconnect(), 20_000); // give up after 20s
const observer = new MutationObserver(() => {
// Microtask: fires after DOM change, BEFORE next paint
const element = document.querySelector(`[data-id="${focusId}"]`);
if (element) {
observer.disconnect();
clearTimeout(timeoutId);
scrollWhenReady(element); // same double RAF chain
}
});
observer.observe(document.body, {
childList: true, // watch for added/removed nodes
subtree: true, // watch entire subtree
attributes: true // catch late-set data attributes
});
return () => {
observer.disconnect();
clearTimeout(timeoutId);
};
}, []);
Note: after finding the element via MutationObserver, we still call scrollWhenReady rather than scrolling directly — because at that microtask moment, the element is in the DOM but not yet painted. Both paths arrive at the same state when the element is found, so both paths take the same final step.
Part 5: Two Additional Problems
Shadow DOM — querySelector Can't Cross the Boundary
In projects using Web Components or micro-frontend Shadow Roots, document.querySelector can't reach elements inside a shadow root. You need recursive traversal:
// Illustrative — recursive shadow DOM traversal
function querySelectorInRoot(selector: string): Element | null {
// Priority 1: try document directly (fast)
const direct = document.querySelector(selector);
if (direct) return direct;
// Priority 2: recurse through all shadow roots
return searchInElement(document.documentElement, selector);
}
function searchInElement(element: Element, selector: string): Element | null {
if (element.shadowRoot) {
const found = element.shadowRoot.querySelector(selector);
if (found) return found;
for (const child of element.shadowRoot.children) {
const result = searchInElement(child, selector);
if (result) return result;
}
}
for (const child of element.children) {
const result = searchInElement(child, selector);
if (result) return result;
}
return null;
}
Replace document.querySelector with querySelectorInRoot in both paths, and Shadow DOM is handled automatically.
Cleanup — Three Sources, Three Layers
Memory leaks come from three places:
// Layer 1: Immediate — stop Observer + cancel timeout
// Triggered: element found / timeout / component unmount
observer.disconnect();
clearTimeout(timeoutId);
// Layer 2: Delayed localStorage cleanup — 10s after scroll
// Delay allows other components time to read the state
setTimeout(() => localStorage.removeItem(STORAGE_KEY), 10_000);
// Layer 3: Custom caller cleanup
// e.g., reset Redux state, clear URL params
onCleanup?.();
One edge case: the double RAF + setTimeout callback chain isn't covered by useEffect cleanup. If the component unmounts during that window, scrollIntoView may still fire. A cancelled flag handles this:
// Illustrative — cancelled flag prevents scroll after unmount
useEffect(() => {
let cancelled = false;
const scrollWhenReady = (element: Element) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setTimeout(() => {
if (cancelled) return;
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 50);
});
});
};
// ... find element ...
return () => { cancelled = true; };
}, []);
Part 6: Composing It Into a Hook
Pull everything into a reusable interface — three parameters map to three concerns:
// Illustrative — useElementFocus hook
interface UseElementFocusOptions {
storageKey: string;
onElementFound: (element: Element) => void; // caller decides the action
onCleanup?: () => void;
timeoutMs?: number;
}
function useElementFocus({ storageKey, onElementFound, onCleanup, timeoutMs = 20_000 }: UseElementFocusOptions) {
useEffect(() => {
const raw = localStorage.getItem(storageKey);
if (!raw) return;
const { focusId } = JSON.parse(raw);
let cancelled = false;
const handleFound = (element: Element) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setTimeout(() => {
if (cancelled) return;
onElementFound(element);
onCleanup?.();
setTimeout(() => localStorage.removeItem(storageKey), 10_000);
}, 50);
});
});
};
const element = querySelectorInRoot(`[data-id="${focusId}"]`);
if (element) {
handleFound(element);
return () => { cancelled = true; };
}
const timeoutId = setTimeout(() => observer.disconnect(), timeoutMs);
const observer = new MutationObserver(() => {
const found = querySelectorInRoot(`[data-id="${focusId}"]`);
if (found) {
observer.disconnect();
clearTimeout(timeoutId);
handleFound(found);
}
});
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
return () => {
cancelled = true;
observer.disconnect();
clearTimeout(timeoutId);
};
}, [storageKey]);
}
Two pages, same hook, different actions:
// Page A: scroll + expand panel
useElementFocus({
storageKey: 'page-a-focus',
onElementFound: (el) => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
dispatch(expandItem(focusId));
},
onCleanup: () => dispatch(clearFocusState()),
});
// Page B: scroll only
useElementFocus({
storageKey: 'page-b-focus',
onElementFound: (el) => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
},
});
The hook's contract: "deliver the element at the right moment." What you do with it is the caller's concern.
Summary
Back to the opening: this isn't three separate problems. It's one problem — doing the right thing at the right moment in the browser rendering pipeline.
| Moment | API | What it does |
|---|---|---|
| Before redirect (outside pipeline) | localStorage |
Persist state, survive cross-tab navigation |
| Macrotask (page load) | useEffect |
Read state, attempt fast path |
| Microtask (after DOM change, before render) | MutationObserver |
Detect element appearance |
| Render phase, Frame N | rAF ① |
Register next-frame operation |
| Render phase, Frame N+1 |
rAF ② + setTimeout
|
Previous frame painted — safe to scroll |
| After element found | 3-layer cleanup | Release Observer, clear storage, reset business state |
The one insight that holds it all together:
"Element exists in DOM" and "element has been painted" are two different moments.
MutationObserveranswers the first. Double RAF answers the second.
This is a writeup of my thinking process while solving this in production — not a prescriptive standard. If you've approached similar problems differently, I'd love to hear it.
One thing I want to explore next: turning this logic into a browser extension. The "wait for element + wait for paint" core is the same — the challenge is element identity across page visits. CSS selectors are too brittle; content-based hashing might be the way.



Top comments (0)