---
title: "How X Handles Infinite Scroll Without Lagging"
description: "Learn how X (Twitter) handles infinite scroll with DOM virtualization and build your own performant version using Next.js and React."
slug: "how-x-handles-infinite-scroll-without-lagging"
url: "https://furkanbaytekin.dev/blogs/how-x-handles-infinite-scroll-without-lagging"
author: "Furkan Baytekin"
category: "Software"
tags: []
published: 2025-04-29T08:07:33Z
updated: 2025-04-29T08:07:33Z
readTime: 4
cover: "https://myblogcms.furkanbaytekin.dev/uploads/cover/how-x-handles-infinite-scroll-without-lagging+1745878861114"
---

Ever wondered how X (formerly Twitter) lets you scroll through thousands of posts without your browser setting itself on fire?

The answer isn't magic. It's smart **rendering logic** — and you can build it **yourself**. We will cover it and reproduce a demo with Next.js in this blog.

Let's break it down and code it from scratch.

## Core Logic Behind Smooth Infinite Scroll

Here’s what’s really happening:

- **Pagination**: Data is loaded **chunk by chunk** (e.g., 20 posts at a time).
- **Threshold Detection**: When you reach near the bottom, load the next chunk.
- **DOM Virtualization**: Only the posts that are close to the viewport are kept in the DOM. Old, far-away posts are temporarily removed.

This technique massively improves performance. Instead of rendering thousands of posts, you just render the few that actually matter.

## Step 1: Basic API for Pagination

```tsx
// pages/api/posts.ts

export const GET = (request) => {
  const { cursor = 0 } = request.query;
  const pageSize = 20;

  const posts = Array.from({ length: pageSize }, (_, i) => ({
    id: Number(cursor) + i + 1,
    content: `Post #${Number(cursor) + i + 1}`,
  }));

  return Response.json({
    posts,
    nextCursor: Number(cursor) + pageSize,
  });
};
```

This mocks loading **20 posts per request**.

## Step 2: Infinite Scroll with Virtualization

```tsx
// pages/index.tsx

import { useEffect, useState, useRef } from "react";

export default function Home() {
  const [posts, setPosts] = useState([]);
  const [cursor, setCursor] = useState(0);
  const [loading, setLoading] = useState(false);
  const [visibleRange, setVisibleRange] = useState([0, 20]);

  const loaderRef = useRef<HTMLDivElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && !loading) {
        loadMore();
      }
    });

    if (loaderRef.current) {
      observer.observe(loaderRef.current);
    }

    return () => {
      if (loaderRef.current) {
        observer.unobserve(loaderRef.current);
      }
    };
  }, [loading]);

  async function loadMore() {
    setLoading(true);
    const res = await fetch(`/api/posts?cursor=${cursor}`);
    const data = await res.json();

    setPosts((prev) => [...prev, ...data.posts]);
    setCursor(data.nextCursor);
    setLoading(false);
  }

  useEffect(() => {
    loadMore(); // initial load
  }, []);

  useEffect(() => {
    function handleScroll() {
      if (!containerRef.current) return;
      const scrollTop = containerRef.current.scrollTop;
      const clientHeight = containerRef.current.clientHeight;

      const postHeight = 80; // Approximate height per post
      const startIndex = Math.max(0, Math.floor(scrollTop / postHeight) - 10);
      const endIndex = Math.floor((scrollTop + clientHeight) / postHeight) + 10;
      setVisibleRange([startIndex, endIndex]);
    }

    const ref = containerRef.current;
    if (ref) ref.addEventListener("scroll", handleScroll);

    return () => {
      if (ref) ref.removeEventListener("scroll", handleScroll);
    };
  }, []);

  return (
    <div
      ref={containerRef}
      style={{
        maxHeight: "90vh",
        overflowY: "auto",
        maxWidth: "600px",
        margin: "0 auto",
      }}
    >
      <div style={{ position: "relative", height: `${posts.length * 80}px` }}>
        {posts.slice(visibleRange[0], visibleRange[1]).map((post, index) => (
          <div
            key={post.id}
            style={{
              position: "absolute",
              top: `${(visibleRange[0] + index) * 80}px`,
              left: 0,
              right: 0,
              padding: "20px",
              borderBottom: "1px solid #ccc",
              background: "white",
            }}
          >
            {post.content}
          </div>
        ))}
      </div>
      <div
        ref={loaderRef}
        style={{ height: "50px", background: "transparent" }}
      />
      {loading && <p>Loading...</p>}
    </div>
  );
}
```

## How This Works

- `IntersectionObserver` still triggers loading more posts.
- **Scroll listener** tracks which posts should be visible based on scroll position.
- Only posts within a **visible window** (+ some buffer) are rendered.
- Posts are absolutely positioned inside a container with a fixed height.

This way, even if there are 10,000 posts, you’re really only rendering 20-30 in the DOM at once.

## Final Touches

For real production:

- Smooth out visible range updates with throttling.
- Handle dynamic post heights if needed.
- Reuse DOM nodes smarter (advanced optimization).

But this basic form already **dramatically** improves performance.

## Conclusion

X's magic lies in **loading only what you need** and **rendering only what you see**.

You just built a real infinite scroll with **Next.js** using smart windowing and no third-party libraries.

Simple. Efficient. Clean.

---

Album of the day:

<iframe style="border-radius:12px" src="https://open.spotify.com/embed/album/30zKZzVoEZjpjmoGtxZ370?utm_source=generator" width="100%" height="152" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
