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.
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
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
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.
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/heightoraspect-ratio, so its final height is known at insertion. - Compensate for late growth with a
ResizeObserveron items above the reader: when one grows, add the growth toscrollTop. BecauseResizeObservercallbacks run before paint, the adjustment is invisible.
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.
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-anchoron 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.
Related
- Restoring Scroll Position in Infinite Scroll — returning to a position after navigation
- Infinite Scroll Inside a Scrollable Container — the root configuration
- Sentinel-Based Windowing for Chat Logs — capping by windowing
↑ Back to Infinite Scroll & Pagination with IntersectionObserver