Bidirectional infinite scroll uses two sentinels — one above the first item and one below the last — and the upward direction needs one extra step: after prepending items, restore the reader's position by adding the inserted height to scrollTop (or relying on correctly configured scroll anchoring) before the browser paints.

Problem / Scenario Context

A support tool opens a conversation at the message a notification linked to — somewhere in the middle of a long history. Readers need to scroll down for newer messages and up for older ones. Loading downward works with the usual sentinel. Loading upward "works" too, in that older messages appear, but every load throws the reader's view down by the height of the new messages: they were reading message 200, and after the load they are looking at message 150 with no idea where they were.

Appending never moves what the reader is looking at; prepending always does, unless you compensate. The Infinite Scroll & Pagination topic covers the downward case; this page covers loading upward.

Mechanics Explanation

A scroll container's scrollTop is measured from the top of its content. When items are inserted above the visible region, the content grows above the reader, and with an unchanged scrollTop the same pixel offset now shows content that is further up. The visible items slide down by exactly the inserted height.

Browsers try to prevent this with scroll anchoring (overflow-anchor: auto, the default): before layout changes, the browser picks an anchor node in the visible area; after the change, it adjusts scrollTop so the anchor keeps its position. It works in many cases, but it is suppressed in some situations — when the scroller is at scrollTop: 0, when script changes the scroll position in the same frame, or when the anchor itself is replaced — and it has no concept of "the message the reader cares about".

That is exactly the situation at the top sentinel: the reader has scrolled up to the very top, scrollTop is at or near 0, and anchoring is least reliable. Manual compensation — measure scrollHeight before and after the insert, add the difference — is deterministic.

Prepending Pushes the Reader's Message DownA scroll container viewport. The top sentinel above the first loaded message has just intersected. Fifty older messages are inserted above. Without compensation the message the reader was looking at is pushed below the viewport by the inserted height. With compensation, scrollTop grows by the same amount and the message stays in place.top sentinel — crossed, load oldermessage being read — must stay heresame message without compensationSolid blue frame: scroll container (root).

Comparison Table: Downward vs Upward Loading

Aspect Loading downward (append) Loading upward (prepend)
Sentinel after the last item before the first item
Visible content moves? no yes, by inserted height
Compensation needed none scrollTop += Δ scrollHeight
Scroll anchoring helps? not needed sometimes; unreliable at the top
Images loading later grow below, no effect grow above, shift the view again
DOM cap remove items from the top, compensate remove items from the bottom, no compensation

Minimal Reproducible Example

TypeScript
const list = document.querySelector<HTMLElement>('.messages')!;     // the scroller
const top = document.querySelector<HTMLElement>('.top-sentinel')!;

new IntersectionObserver(async ([e]) => {
  if (!e.isIntersecting) return;
  const older = await fetchOlder();
  top.after(...older.map(renderMessage));        // prepend below the sentinel
}, { root: list, rootMargin: '300px 0px 0px 0px' }).observe(top);

declare function fetchOlder(): Promise<Msg[]>;
declare function renderMessage(m: Msg): HTMLElement;
interface Msg { id: string }

Scroll up to trigger a load: the view jumps by the height of the inserted messages.

Production-Safe Solution

TypeScript
interface TwoWayOptions {
  scroller: HTMLElement;
  topSentinel: HTMLElement;
  bottomSentinel: HTMLElement;
  loadOlder: () => Promise<HTMLElement[]>;
  loadNewer: () => Promise<HTMLElement[]>;
  maxItems?: number;
}

export function twoWayScroll(o: TwoWayOptions): () => void {
  const { scroller, topSentinel, bottomSentinel, maxItems = 400 } = o;
  let busyTop = false, busyBottom = false, doneTop = false, doneBottom = false;
  scroller.style.overflowAnchor = 'none';                 // we compensate ourselves

  const items = () => [...scroller.children].filter((c) => c !== topSentinel && c !== bottomSentinel) as HTMLElement[];

  async function older(): Promise<void> {
    if (busyTop || doneTop) return;
    busyTop = true;
    const nodes = await o.loadOlder();
    if (!nodes.length) { doneTop = true; busyTop = false; return; }
    const before = scroller.scrollHeight;
    topSentinel.after(...nodes);
    scroller.scrollTop += scroller.scrollHeight - before;   // same task, before paint
    // Cap: drop from the far (bottom) end; no compensation needed there.
    const all = items();
    for (const el of all.slice(maxItems)) el.remove();
    if (all.length > maxItems) doneBottom = false;
    busyTop = false;
  }

  async function newer(): Promise<void> {
    if (busyBottom || doneBottom) return;
    busyBottom = true;
    const nodes = await o.loadNewer();
    if (!nodes.length) { doneBottom = true; busyBottom = false; return; }
    bottomSentinel.before(...nodes);                       // append: nothing moves
    // Cap: drop from the top end, which does need compensation.
    const all = items();
    const excess = all.slice(0, Math.max(0, all.length - maxItems));
    if (excess.length) {
      const before = scroller.scrollHeight;
      excess.forEach((el) => el.remove());
      scroller.scrollTop -= before - scroller.scrollHeight;
      doneTop = false;
    }
    busyBottom = false;
  }

  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (!e.isIntersecting) continue;
      e.target === topSentinel ? void older() : void newer();
    }
  }, { root: scroller, rootMargin: '400px 0px' });
  io.observe(topSentinel);
  io.observe(bottomSentinel);
  return () => io.disconnect();
}

Reading scrollHeight after the insertion forces a layout, but it is one layout per load, performed before the browser paints, which is what keeps the reader's message visually fixed. Capping in both directions keeps the DOM bounded during long sessions, and each cap resets the opposite direction's "done" flag so trimmed content can be loaded again.

Loading Upward Without a JumpFive steps. The top sentinel intersects within the scroller's margin. Older messages are fetched. The scroll height is read, the messages are inserted after the sentinel, and the scroll height is read again. The difference is added to scrollTop in the same task. If the list exceeds its cap, the newest messages at the bottom are removed, which needs no compensation.1Top sentinel crossesWithin the scroller's 400px margin.2Fetch olderGuarded so only one upward load runs at a time.3Measure, insert, measurescrollHeight before and after the prepend.4CompensatescrollTop += difference, before paint.5Cap at the far endRemove newest items at the bottom; no compensation.

Late Growth: Images and Embeds

Compensation measures height at insertion time. Anything that grows afterwards above the reader — images without reserved dimensions, link previews, embedded tweets — shifts the view again, seconds later, when it loads. Two defences:

  • Reserve space for every media element with width/height or aspect-ratio, so its final height is known at insertion.
  • Compensate for late growth with a ResizeObserver on items above the reader: when one grows, add the growth to scrollTop. Because ResizeObserver callbacks run before paint, the adjustment is invisible.
TypeScript
const heights = new WeakMap<Element, number>();
const growth = new ResizeObserver((entries) => {
  for (const e of entries) {
    const h = e.borderBoxSize[0].blockSize;
    const prev = heights.get(e.target) ?? h;
    heights.set(e.target, h);
    const el = e.target as HTMLElement;
    if (el.offsetTop + h < scroller.scrollTop) scroller.scrollTop += h - prev;   // grew above view
  }
});

declare const scroller: HTMLElement;

Keyboard and screen-reader users need the same stability; the focus side of prepending is covered in keeping keyboard focus stable when content loads.

Two Causes of Upward JumpsTwo columns. Jumps at insertion happen when prepended content pushes the view down; they are fixed by measuring scroll height before and after and compensating in the same task. Late jumps happen when media above the reader loads and grows seconds later; they are fixed by reserving space or compensating from a ResizeObserver before paint.Jump at insertionPrepended items push the view downFix: measure, insert, measure, add the differenceSame task, before paintLate jumpImages or embeds above grow after loadingFix: reserve space with width/heightOr compensate from a ResizeObserver, before paint

Verification Steps

  • Scroll up slowly and quickly and confirm the message you are reading never moves when older messages load.
  • Load images above the reader on a throttled connection and confirm no late jump.
  • Open a deep link in the middle of history and scroll both ways several pages.
  • Check the DOM size stays near the cap after long sessions.
  • Test with overflow-anchor on and off to confirm manual and native compensation are not both applied.

Common Mistakes to Avoid

  • Relying on scroll anchoring at scrollTop: 0. It is least reliable exactly where the top sentinel fires.
  • Compensating in requestAnimationFrame. The jumped frame is painted first.
  • Capping from the top without compensation. Removing content above the reader shifts the view the other way.
  • Ignoring late-loading media. Reserved dimensions matter even more above the reader than below.

FAQ

Why not use flex-direction: column-reverse for chat?

It makes the bottom the scroll origin, so appending new messages at the logical end keeps the view stable without compensation. It complicates accessibility order and selection in some browsers and does not help with loading older content, which then becomes the moving side.

Does scroll anchoring work in scroll containers, not just the document?

Yes, overflow-anchor applies to any scroll container. The limitation is how it chooses the anchor and when it is suppressed, not where it applies.

How big should the top margin be?

Large enough that older content arrives before the reader reaches the top: a screen or two of height, adjusted by how long the fetch takes. A smaller margin makes the reader wait at the top edge.

What if the first load does not fill the scroller?

Then both sentinels may intersect at once, triggering loads in both directions. That is correct behaviour; the guards keep each direction to one request at a time.

How do I jump to a specific message deep in history?

Load a window of messages around it from the server, render them with both sentinels, and scroll the target into view. The two-way loader then extends the window in whichever direction the reader moves.

Is the forced layout from reading scrollHeight a performance problem?

It happens once per load, and the browser would lay out the inserted content before the next paint anyway. It is the same layout, done a little earlier so the compensation can be applied in the same frame.


↑ Back to Infinite Scroll & Pagination with IntersectionObserver