An IntersectionObserver reports a crossing after the frame that contained it has already been painted, so any style you apply in response appears one frame later — invisible for fades, very visible for anything that must line up with the scroll position.

Problem / Scenario Context

A documentation site pins a "section progress" marker to the left edge of each heading when the heading reaches the top of the viewport. The marker is positioned by adding a .pinned class from an observer callback. Scrolling slowly looks fine. Flicking the trackpad makes the marker visibly trail the heading: for one frame the heading has moved but the marker is still in its old place, then it snaps.

The same code on a fade-in animation shows no problem at all. The difference is not the observer but what the effect is tied to. The Rendering Pipeline & Observer Timing topic shows why the callback lands where it does; this page is about recognising and removing the visible lag.

Mechanics Explanation

Take frame N. During its rendering steps the browser computes that the heading now intersects the root, queues an entry, and paints frame N using the styles that existed before the callback — no .pinned class. The callback task runs after paint, adds the class, and frame N + 1 is the first one painted with the marker in place.

Whether this is visible depends on how far the relevant geometry moved between frames:

  • A fade changes opacity over 200 ms. Starting one frame (16 ms) late is imperceptible.
  • A position tied to scroll must match the scroll offset of the frame being painted. If the page scrolled 40 px in frame N, the marker is 40 px wrong for one frame.
  • A layout swap (sticky toolbar replacing an inline one) shows both or neither for a frame, which reads as a flash.

The faster the scroll, the larger the per-frame displacement and the more visible the lag. That is why trackpad flicks and touch flings expose it while wheel steps often do not.

The One-Frame Gap on a Fast ScrollTwo frames side by side. In frame N the heading crosses the top edge, the intersection is computed, and the frame is painted without the pinned class. The callback runs after paint and adds the class. Frame N plus one is the first painted with the marker in the right place. The displacement during frame N is the visible error.Fast scroll, 40 px per frameframe NscrollIO computedpainted unpinnedcallback taskadd .pinnedframe N+1layoutpainted pinned0ms4ms8ms12ms16ms20ms24ms28ms32msvsync

Comparison Table: Which Effects Show the Lag

Effect driven by the callback Tied to scroll position? One-frame lag visible? Better mechanism
Fade or slide-in reveal no no keep IntersectionObserver
Lazy-load an image no no keep IntersectionObserver
Pin a marker to a heading yes yes, on fast scroll position: sticky
Swap inline toolbar for fixed toolbar yes yes, as a flash sticky, or overlap both for a frame
Progress bar width yes slightly scroll-driven CSS animation
Analytics impression no no keep IntersectionObserver

Minimal Reproducible Example

TypeScript
// The marker is absolutely positioned by class; the class arrives a frame late.
const heading = document.querySelector<HTMLElement>('h2#install')!;
const marker = document.querySelector<HTMLElement>('.marker')!;

new IntersectionObserver(([e]) => {
  marker.classList.toggle('pinned', e.boundingClientRect.top <= 0);
}, { threshold: [0, 1] }).observe(heading);
CSS
.marker        { position: absolute; top: var(--heading-offset); }
.marker.pinned { position: fixed; top: 0; }

Record with the Performance panel's screenshots enabled and flick the trackpad; one screenshot will show the heading at the top and the marker still in flow.

Frame N Versus Frame N Plus OneA viewport with the heading already pushed past the top edge during frame N while the marker is still drawn at its old in-flow position, forty pixels too low. In the next frame the pinned class is applied and the marker sits at the top edge where it belongs.heading — crossed the top edge this framemarker — still in flow, 40 px behindcontent scrolling underneathSolid blue frame: viewport at frame N.One frame later the class lands and the marker jumps to the top edge. At 120 Hz the error is halved but not removed.

Production-Safe Solution

Remove the dependency on callback timing for anything that must track scroll. The platform has two mechanisms that are resolved in the same frame as the scroll, because the compositor or the layout engine handles them directly.

CSS
/* 1. Let layout do the pinning: sticky is resolved in the frame being painted. */
.marker {
  position: sticky;
  top: 0;
}

/* 2. For purely visual progress, use a scroll-driven animation (no JS in the loop). */
@supports (animation-timeline: scroll()) {
  .progress {
    transform-origin: left;
    animation: grow linear both;
    animation-timeline: scroll(root block);
  }
  @keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
}

Keep the observer for the semantic part of the effect, which tolerates a frame of lag — announcing the section, updating the table of contents, sending analytics:

TypeScript
type SectionHandler = (id: string) => void;

export function observeActiveSection(headings: HTMLElement[], onActive: SectionHandler): () => void {
  const io = new IntersectionObserver(
    (entries) => {
      // Semantic state only: a frame of lag here is invisible.
      const top = entries
        .filter((e) => e.isIntersecting)
        .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
      if (top) onActive(top.target.id);
    },
    { rootMargin: '0px 0px -70% 0px' },
  );
  headings.forEach((h) => io.observe(h));
  return () => io.disconnect();
}

This split is the same one the sticky header guide uses: CSS positions, the observer only reports state.

Deciding Per Effect: A Quick Audit

Most pages have a handful of observer-driven effects, and only some of them are sensitive to the lag. Auditing them one at a time is faster than rewriting everything in CSS.

Ask one question per effect: if this change appeared 16 ms late, would a user see the element and its decoration disagree? A reveal that starts one frame late looks identical. An image that swaps from placeholder one frame late looks identical. A marker, underline, shadow or sticky clone that must sit at a scroll-dependent position does not — the decoration and the element visibly disagree for a frame, and on a fling the disagreement can be tens of pixels.

For effects that fail the question, there are three options in order of preference:

  1. Express it in CSSposition: sticky, animation-timeline: scroll() or view(), container queries. These are resolved during the frame being painted, often on the compositor.
  2. Make both states valid for a frame. Render the sticky clone underneath the original all the time and reveal it by clipping, so there is no frame in which neither is visible.
  3. Accept it and reduce the displacement. Snap-scrolling containers and reduced scroll velocity (for example on a carousel with scroll-snap-type) shrink the per-frame movement until the lag is below perception.

The observer keeps its job in every case — deciding which section is active, whether a clone should exist — because those decisions are semantic, not positional.

Is the One-Frame Lag Visible for This Effect?A decision chain for auditing effects. If the effect is a reveal, fade or lazy load, keep IntersectionObserver. Otherwise, if the effect can be expressed with sticky positioning or a scroll-driven animation, move it to CSS. Otherwise, if both states can be rendered for one frame, overlap them. Otherwise, accept the lag and reduce scroll displacement with snapping.Is it a reveal, fade or lazy load?Keep the observer — a frame of lag is invisibleyesnoCan CSS sticky or a scroll timeline express it?Move it to CSS; keep the observer for state onlyyesnoCan both states be rendered for one frame?Overlap them so no frame shows neitheryesnoAccept the lag and reduce per-frame displacement, for example with scroll snapping.

Verification Steps

  • Enable screenshots in a Performance recording and step frame by frame through a fast flick; the marker should never trail the heading.
  • Test at 120 Hz and at 60 Hz (DevTools rendering panel can emulate lower refresh); the fix must not depend on refresh rate.
  • Throttle the CPU 6× and repeat; sticky and scroll-driven animations are unaffected by main-thread load, observer-driven positioning is not.
  • Check the semantic updates still fire by logging onActive during the same scroll.

Common Mistakes to Avoid

  • Chasing the lag with requestAnimationFrame. It moves the write to the start of frame N + 1, which is where it landed already.
  • Reading scrollY in a scroll listener instead. Scroll events are also delivered after the compositor has scrolled, and on the main thread; you trade one lag for another plus the listener cost.
  • Adding more thresholds. More crossings means more tasks; it does not make any single one arrive earlier.
  • Blaming the observer for layout-swap flashes. Render both states for one frame, or use sticky, rather than toggling between them.

FAQ

Is the one-frame lag a browser bug?

No. It is specified behaviour: intersections are computed during the rendering steps and delivered by a queued task. Delivering synchronously during rendering would let script block paint, which is exactly what the design avoids.

Does a 120 Hz display fix it?

It halves the duration of the wrong frame and roughly halves the per-frame scroll displacement, so the error becomes less visible. It does not remove it.

Why does ResizeObserver not have this problem?

Its callbacks run inside the rendering steps, after layout and before paint, so a write made in the callback is painted in the same frame. The trade-off is the loop guard that stops callbacks from resizing each other forever.

When should I still drive visuals from IntersectionObserver?

Whenever the effect is not pinned to the scroll position: reveals, lazy loading, pausing media, starting and stopping animations. A frame of lag in those cases is below the threshold of perception.


↑ Back to Rendering Pipeline & Observer Timing