DEV Community

Cover image for Scroll Restoration After Micro-Frontend Redirects: Double RAF + MutationObserver
yuki uix
yuki uix

Posted on

Scroll Restoration After Micro-Frontend Redirects: Double RAF + MutationObserver

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:

browser-event-loop

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
Enter fullscreen mode Exit fullscreen mode

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 focusId into 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);
Enter fullscreen mode Exit fullscreen mode

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:

  • setInterval polling: 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:

Why-does-microtask-timing-matter

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":

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);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

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);
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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?.();
Enter fullscreen mode Exit fullscreen mode

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; };
}, []);
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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' });
  },
});
Enter fullscreen mode Exit fullscreen mode

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. MutationObserver answers 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.


References

Top comments (0)