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: