For chat logs, keep a sliding window of a few hundred rendered messages between a top and a bottom sentinel: when a sentinel approaches, render the next chunk on that side and remove a chunk from the far side (compensating scrollTop when removing above), and keep a "stuck to bottom" flag so new messages only auto-scroll readers who were already at the end.

Problem / Scenario Context

A team chat app renders every message ever loaded in a channel. Busy channels reach 20,000 DOM nodes after a morning; typing lags, switching channels takes a second, and memory climbs until the tab is reloaded. A full virtual list library was tried, but chat's properties — highly variable heights (code blocks, images, reactions), frequent appends at the bottom, rare random access — made it fragile: rows jumped when images loaded, and "scroll to bottom on new message" fought the virtualiser.

Chat does not need arbitrary windowing. It needs a bounded contiguous window that slides. The Virtual Lists & Windowing with Observers topic covers full virtualisation; this page covers the simpler sliding window.

Mechanics Explanation

A sliding window renders messages [start, end) from an in-memory array, with sentinels at both ends. Unlike a virtual list, rendered messages are in normal document flow — no absolute positioning, no offset index — so variable heights and late-loading images are handled by the browser's own layout. The costs are shifted to the window edges:

  • Extending at the bottom (append) never moves visible content.
  • Extending at the top (prepend) moves content down by the inserted height; compensate scrollTop, as in bidirectional infinite scroll.
  • Trimming at the bottom never moves visible content.
  • Trimming at the top moves content up by the removed height; compensate the other way.

Because the window is contiguous and the DOM is in flow, the only measurements needed are scrollHeight before and after each top-side change. An IntersectionObserver with the scroller as root detects when either sentinel comes within a margin, which is the only trigger.

The stick-to-bottom behaviour is a separate concern: when a new message arrives, scroll to the bottom only if the reader was already at the bottom. A third sentinel at the very end, observed with a small margin, tracks that state without scroll listeners.

The Sliding Window in a Chat ScrollerFour stacked layers from top to bottom of the scroller. The top sentinel triggers rendering older messages and trimming newer ones. The rendered window holds a few hundred messages in normal flow. The bottom sentinel triggers rendering newer messages when the reader has scrolled up. The end sentinel tracks whether the reader is stuck to the bottom for auto-scrolling on new messages.Top sentinelNear: render older chunk above, trim below, compensate scrollTop.Rendered window~300 messages in normal flow; browser handles heights.Bottom sentinelNear: render newer chunk below, trim above, compensate.End sentinelVisible ⇒ stuck to bottom ⇒ auto-scroll on new messages.

Comparison Table: Full Virtual List vs Sliding Window for Chat

Concern Full virtual list Sliding window
DOM size only visible rows a few hundred messages
Variable heights measured + offset index browser layout, in flow
Late-loading images must re-measure handled by layout (compensate only above)
Random access (jump to message) direct reload the window around it
Text search with Ctrl+F only visible rows the whole window
Complexity high moderate

Minimal Reproducible Example

TypeScript
socket.onmessage = (ev) => {
  const msg = renderMessage(JSON.parse(ev.data));
  log.append(msg);                               // DOM grows forever
  log.scrollTop = log.scrollHeight;              // yanks readers who scrolled up
};

declare const socket: WebSocket; declare const log: HTMLElement;
declare function renderMessage(m: unknown): HTMLElement;

Production-Safe Solution

TypeScript
interface Msg { id: string }

export class ChatWindow {
  #start = 0; #end = 0;
  #stuck = true;
  #io: IntersectionObserver;
  #endIO: IntersectionObserver;

  constructor(
    private scroller: HTMLElement,
    private list: HTMLElement,                   // contains top sentinel, messages, bottom sentinel, end sentinel
    private top: HTMLElement,
    private bottom: HTMLElement,
    private end: HTMLElement,
    private messages: Msg[],
    private render: (m: Msg) => HTMLElement,
    private chunk = 50,
    private max = 300,
  ) {
    this.scroller.style.overflowAnchor = 'none';
    this.#start = Math.max(0, messages.length - chunk);
    this.#end = messages.length;
    this.bottom.before(...messages.slice(this.#start, this.#end).map(render));

    this.#io = new IntersectionObserver((es) => {
      for (const e of es) if (e.isIntersecting) (e.target === top ? this.#older() : this.#newer());
    }, { root: scroller, rootMargin: '600px 0px' });
    this.#io.observe(top); this.#io.observe(bottom);

    this.#endIO = new IntersectionObserver(([e]) => { this.#stuck = e.isIntersecting; },
      { root: scroller, rootMargin: '0px 0px 40px 0px' });
    this.#endIO.observe(end);
    scroller.scrollTop = scroller.scrollHeight;
  }

  #nodes(): HTMLElement[] { return [...this.list.children].filter((c) => c.hasAttribute('data-msg')) as HTMLElement[]; }

  #older(): void {
    if (this.#start === 0) return;
    const from = Math.max(0, this.#start - this.chunk);
    const before = this.scroller.scrollHeight;
    this.top.after(...this.messages.slice(from, this.#start).map(this.render));
    this.#start = from;
    this.scroller.scrollTop += this.scroller.scrollHeight - before;          // keep view still
    // Trim the far (bottom) end: no compensation needed.
    const nodes = this.#nodes();
    for (const n of nodes.slice(this.max)) n.remove();
    this.#end = this.#start + Math.min(nodes.length, this.max);
  }

  #newer(): void {
    if (this.#end >= this.messages.length) return;
    const to = Math.min(this.messages.length, this.#end + this.chunk);
    this.bottom.before(...this.messages.slice(this.#end, to).map(this.render));
    this.#end = to;
    const nodes = this.#nodes();
    const excess = nodes.length - this.max;
    if (excess > 0) {
      const before = this.scroller.scrollHeight;
      nodes.slice(0, excess).forEach((n) => n.remove());
      this.#start += excess;
      this.scroller.scrollTop -= before - this.scroller.scrollHeight;       // removed above: compensate
    }
  }

  /** New live message. */
  push(m: Msg): void {
    this.messages.push(m);
    if (this.#end === this.messages.length - 1) {                           // window reaches the end
      this.bottom.before(this.render(m));
      this.#end++;
      if (this.#stuck) this.scroller.scrollTop = this.scroller.scrollHeight;
      if (this.#nodes().length > this.max) this.#newer();                   // re-trim the top
    }
  }
}

render must set data-msg on each message element. Messages outside the window are in memory as data, not DOM. The top and bottom sentinels drive the window; the end sentinel's small margin defines "at the bottom" with a little tolerance, so a reader two pixels from the end still counts as stuck. New messages extend the window only when it already reaches the end; otherwise they wait in the array until the reader scrolls down.

DOM Size Over a Busy MorningA timeline across four hours in a busy channel. Without windowing, rendered messages grow steadily past four thousand. With the sliding window, rendered messages rise to the three hundred cap in the first minutes and stay there while messages keep arriving.Busy channel, 9:00 to 13:00no window~1,000 msgs~2,000~3,0004,000+sliding windowcapped at 300 rendered messages0h1h2h3h4h

Jumping to a Message

Random access is the sliding window's weak spot: there is no offset index to scroll to. The solution is to move the window instead of scrolling to a distant offset:

  1. Find the message's index in the data array.
  2. Replace the window's contents with [index − chunk, index + chunk).
  3. Scroll the target message into view (scrollIntoView({ block: 'center' })) and focus it for keyboard users.
  4. Mark the reader as not stuck to the bottom; the end sentinel will correct that if the target was near the end.

The sentinels then extend the window naturally as the reader scrolls in either direction. For search results, highlighted mentions and deep links from notifications, this is simpler and more robust than maintaining offsets for tens of thousands of variable-height messages.

Jumping to an Old MessageFour boxes. The target message's index is found in the in-memory array. The window is replaced with messages around that index. The target is scrolled into the centre and focused. The sentinels then extend the window as the reader scrolls in either direction.Find indexin the message arrayReplace windowindex ± chunkScroll + focustarget centredSentinels resumeextend on scroll

Verification Steps

  • Leave a busy channel open for an hour and confirm the number of rendered messages stays at the cap.
  • Scroll up through history; the visible message must not jump when older chunks render or newer ones are trimmed.
  • Receive messages while scrolled up; the view must not move, and a "new messages" indicator can show the count.
  • Receive messages while at the bottom; the log must stay scrolled to the end.
  • Jump to a message from search and scroll both ways from there.

Common Mistakes to Avoid

  • Auto-scrolling on every new message. Only scroll readers who were at the bottom.
  • Trimming above without compensation. The view jumps up by the removed height.
  • Leaving overflow-anchor on while compensating manually — the adjustments stack.
  • Rendering new messages outside the window. Keep them as data until the window reaches them.

FAQ

Why not use a full virtual list for chat?

You can, but chat messages vary widely in height, change size as media loads, and are mostly appended at the end. A sliding window keeps them in normal flow, so the browser handles heights, and only the window edges need care.

How big should the window be?

A few hundred messages is a good balance: enough that scrolling rarely hits a sentinel mid-read, small enough that layout and memory stay light. Tune the chunk size so each extension takes well under a frame to render.

How do I detect that the reader is at the bottom without scroll events?

Observe a sentinel after the last message with a small bottom rootMargin. When it intersects, the reader is at, or within a few pixels of, the bottom.

Does Ctrl+F find messages outside the window?

No, only rendered ones. Provide in-app search over the message array, which then jumps the window to the result.

What happens to images in trimmed messages?

They are removed with their messages and released by the browser. When the message is rendered again, the image is usually served from the HTTP cache.

How do screen readers experience the window?

They see the rendered messages in order. Use role="log" on the list so new messages are announced politely, and ensure focus is never on a message that gets trimmed.


↑ Back to Virtual Lists & Windowing with Observers