Amblem
Furkan Baytekin

How X Handles Infinite Scroll Without Lagging

Build X's infinite scroll from scratch with Next.js

How X Handles Infinite Scroll Without Lagging
152
4 minutes

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:

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

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:

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:

Suggested Blog Posts