When you are building a social feed, data grid, or chat UI, everything feels fine with 10 mock items. Then you connect a real API, render 50,000 rows with myList.map(...), and the browser locks up.
The core problem is simple: you are asking the DOM to do too much work.
Virtualization solves this by rendering only what the user can actually see. Instead of mounting 50,000 nodes, you render the 15–20 items that are visible in the viewport, plus a small buffer. The browser now only manages a few dozen elements at a time.
TanStack Virtual provides the heavy lifting for this pattern. It is a modern, headless virtualization utility: it handles scroll math, size calculations, and item positioning, while you keep full control over markup and styles.
In this article, you will build a high-performance, real-world livestream chat feed from scratch. The feed will:
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
Start with a new React + TypeScript project using Vite:
npx create-vite@latest tanstack-virtual-chat --template react-ts cd tanstack-virtual-chat
You only need two extra dependencies:
@tanstack/react-virtual for virtualization@faker-js/faker for generating a large, realistic datasetInstall them:
npm install @tanstack/react-virtual @faker-js/faker
Then start the dev server:
npm run dev
You should see the default Vite + React starter page.
Virtualization will not work unless the virtualizer knows the viewport it is responsible for. That means you must provide:
overflow-y: auto)Replace the content of src/App.css with:
/* src/App.css */
body {
font-family: sans-serif;
padding: 2rem;
display: grid;
place-items: center;
min-height: 100vh;
}
.chat-container {
/* You MUST provide a defined height. */
height: 600px;
width: 400px;
/* You MUST provide an overflow property. */
overflow-y: auto;
border: 1px solid #ccc;
border-radius: 8px;
}
.chat-bubble {
padding: 12px 16px;
border-bottom: 1px solid #eee;
display: flex;
gap: 8px;
}
.chat-bubble strong {
color: #333;
min-width: 70px;
}
.chat-bubble p {
margin: 0;
color: #555;
/* Allow long messages to wrap */
word-break: break-word;
}
You can clear out the default styles in src/index.css.
Next, set up a simple viewport shell in src/App.tsx:
// src/App.tsx
import './App.css'
function App() {
return (
<div>
<h1>Livestream Chat Feed</h1>
{/* This div is our scrollable viewport */}
<div className="chat-container">
{/* We will render our virtualized list here */}
</div>
</div>
)
}
export default App
At this point, the browser should show a title and an empty 600px box. This is the viewport the virtualizer will work with.
To demonstrate the performance problem, you need a lot of messages. Use Faker to generate 10,000 chat messages.
Create src/utils.ts:
// src/utils.ts
import { faker } from '@faker-js/faker'
export type ChatMessage = {
id: string
author: string
message: string
}
const createRandomMessage = (): ChatMessage => {
return {
id: faker.string.uuid(),
author: faker.person.firstName(),
message: faker.lorem.sentences({ min: 1, max: 15 }),
}
}
// Create a massive list of 10,000 messages
export const allMessages = Array.from({ length: 10_000 }, createRandomMessage)
You now have:
allMessages array with 10,000 itemsNext, you will see why you need virtualization.
First, render the entire list in App.tsx using a regular .map():
// src/App.tsx
import './App.css'
import { allMessages } from './utils'
function App() {
return (
<div>
<h1>Livestream Chat Feed</h1>
<div className="chat-container">
{allMessages.map((msg) => (
<div key={msg.id} className="chat-bubble">
<strong>{msg.author}</strong>
<p>{msg.message}</p>
</div>
))}
</div>
</div>
)
}
export default App
Save and reload. The browser will likely freeze, crash, or at least take a long time to become responsive. You are trying to create 10,000 DOM nodes at once.
This is the baseline you are optimizing away from.
useVirtualizerNow integrate TanStack Virtual and connect it to your scroll container.
// src/App.tsx
import './App.css'
import React from 'react'
import { allMessages } from './utils'
import { useVirtualizer } from '@tanstack/react-virtual'
function App() {
const parentRef = React.useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: allMessages.length, // Total number of items
getScrollElement: () => parentRef.current, // The scrolling element
estimateSize: () => 50, // Approximate row height
})
return (
<div>
<h1>Livestream Chat Feed</h1>
<div ref={parentRef} className="chat-container">
{/* We will render the virtual items here */}
</div>
</div>
)
}
export default App
You have now told the virtualizer:
TanStack Virtual is headless. It gives you:
getTotalSize() function that returns the full list height (for the scrollbar)getVirtualItems() array that describes the currently visible rowsTo use this, you need two CSS rules:
<div> with position: relative and height set to getTotalSize()virtualItem.startHere is the full version of App.tsx with basic virtualization wired up:
// src/App.tsx
import './App.css'
import React from 'react'
import { allMessages } from './utils'
import { useVirtualizer } from '@tanstack/react-virtual'
function App() {
const parentRef = React.useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: allMessages.length,
getScrollElement: () => parentRef.current,
// A more realistic estimate for our chat bubbles
estimateSize: () => 88,
})
const virtualItems = rowVirtualizer.getVirtualItems()
return (
<div>
<h1>Livestream Chat Feed</h1>
<div ref={parentRef} className="chat-container">
{/* Sizer div */}
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{/* Only render the virtual items */}
{virtualItems.map((virtualItem) => {
const message = allMessages[virtualItem.index]
return (
<div
key={message.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className="chat-bubble">
<strong>{message.author}</strong>
<p>{message.message}</p>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
export default App
Reload the page. Scrolling through 10,000 items should now feel instant and smooth, because the DOM only contains the items in view.
There is one remaining problem: all rows are treated as if they share the same height estimate, so long messages are clipped.
Right now you tell the virtualizer that every row is 88px tall:
estimateSize: () => 88
In a real chat feed, messages vary significantly in length and height. To handle this, TanStack Virtual can measure each node after it renders.
You need two things:
measureElement function passed to useVirtualizerref and data-index on each measured elementUpdate App.tsx:
// src/App.tsx
import './App.css'
import React from 'react'
import { allMessages } from './utils'
import { useVirtualizer } from '@tanstack/react-virtual'
function App() {
const parentRef = React.useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: allMessages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 88,
measureElement: (element) => element.getBoundingClientRect().height,
})
const virtualItems = rowVirtualizer.getVirtualItems()
return (
<div>
<h1>Livestream Chat Feed</h1>
<div ref={parentRef} className="chat-container">
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualItem) => {
const message = allMessages[virtualItem.index]
return (
<div
key={message.id}
data-index={virtualItem.index}
ref={rowVirtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className="chat-bubble">
<strong>{message.author}</strong>
<p>{message.message}</p>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
export default App
Now each row is measured after render, and the virtualizer updates its internal size map. The list remains smooth while correctly spacing tall and short messages.

The last step is to move from a static list to a realistic chat experience:
This involves three main changes:
allMessages as a database and keep a window of messages in stateRefactor App so that you only keep the latest 100 messages in state, plus some metadata about loading:
// src/App.tsx
import './App.css'
import React from 'react'
import { allMessages, type ChatMessage } from './utils'
import { useVirtualizer } from '@tanstack/react-virtual'
// Pretend this is our "database"
const dbMessages = allMessages
const LATEST_MESSAGES_COUNT = 100
function App() {
const [messages, setMessages] = React.useState<ChatMessage[]>(
dbMessages.slice(dbMessages.length - LATEST_MESSAGES_COUNT)
)
const [isFetching, setIsFetching] = React.useState(false)
const [hasNextPage, setHasNextPage] = React.useState(
messages.length < dbMessages.length
)
// More logic will go here
}
Next, add a function that pretends to fetch older messages. It locates the current oldest message in dbMessages, then grabs the previous 100 and prepends them to state.
function App() {
const [messages, setMessages] = React.useState<ChatMessage[]>(
dbMessages.slice(dbMessages.length - LATEST_MESSAGES_COUNT)
)
const [isFetching, setIsFetching] = React.useState(false)
const [hasNextPage, setHasNextPage] = React.useState(
messages.length < dbMessages.length
)
const fetchMoreMessages = React.useCallback(() => {
if (isFetching) return
setIsFetching(true)
setTimeout(() => {
const currentOldestMessage = messages[0]
const oldestMessageIndex = dbMessages.findIndex(
(msg) => msg.id === currentOldestMessage.id
)
const newOldestIndex = Math.max(0, oldestMessageIndex - 100)
const newMessages = dbMessages.slice(newOldestIndex, oldestMessageIndex)
setMessages((prev) => [...newMessages, ...prev])
if (newOldestIndex === 0) {
setHasNextPage(false)
}
setIsFetching(false)
}, 1000)
}, [isFetching, messages])
This setTimeout simulates network latency.
Now wire up the virtualizer to this paginated model.
The list count becomes messages.length + 1 if there is another page of history (the extra row is a loader). Otherwise, it is just messages.length:
const parentRef = React.useRef<HTMLDivElement | null>(null)
const hasScrolledRef = React.useRef(false)
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? messages.length + 1 : messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 88,
measureElement: (element) => element.getBoundingClientRect().height,
})
const virtualItems = rowVirtualizer.getVirtualItems()
Add an effect that watches the first visible virtual row. If its index is 0, you have reached the top of the list. If there are more pages and you are not already fetching, call fetchMoreMessages:
React.useEffect(() => {
const [firstItem] = virtualItems
if (!firstItem) return
if (firstItem.index === 0 && hasNextPage && !isFetching) {
fetchMoreMessages()
}
}, [virtualItems, hasNextPage, isFetching, fetchMoreMessages])
Chat UIs default to the newest message. Use a second effect to scroll to the last real message once, on mount:
React.useEffect(() => {
if (virtualItems.length > 0 && !hasScrolledRef.current) {
const lastMessageIndex = hasNextPage ? messages.length : messages.length - 1
rowVirtualizer.scrollToIndex(lastMessageIndex, { align: 'end' })
hasScrolledRef.current = true
}
}, [virtualItems, rowVirtualizer, messages.length, hasNextPage])
When hasNextPage is true, the virtual row at index === 0 is reserved for a loader (“Loading older messages…”). Real messages then start at index 1.
You can compute the correct message index like this:
const messageIndex = hasNextPage ? virtualItem.index - 1 : virtualItem.index
Here is the final App component with everything wired together:
// src/App.tsx
import './App.css'
import React from 'react'
import { allMessages, type ChatMessage } from './utils'
import { useVirtualizer } from '@tanstack/react-virtual'
const dbMessages = allMessages
const LATEST_MESSAGES_COUNT = 100
function App() {
const [messages, setMessages] = React.useState<ChatMessage[]>(
dbMessages.slice(dbMessages.length - LATEST_MESSAGES_COUNT)
)
const [isFetching, setIsFetching] = React.useState(false)
const [hasNextPage, setHasNextPage] = React.useState(
messages.length < dbMessages.length
)
const parentRef = React.useRef<HTMLDivElement | null>(null)
const hasScrolledRef = React.useRef(false)
const fetchMoreMessages = React.useCallback(() => {
if (isFetching) return
setIsFetching(true)
setTimeout(() => {
const currentOldestMessage = messages[0]
const oldestMessageIndex = dbMessages.findIndex(
(msg) => msg.id === currentOldestMessage.id
)
const newOldestIndex = Math.max(0, oldestMessageIndex - 100)
const newMessages = dbMessages.slice(newOldestIndex, oldestMessageIndex)
setMessages((prev) => [...newMessages, ...prev])
if (newOldestIndex === 0) {
setHasNextPage(false)
}
setIsFetching(false)
}, 1000)
}, [isFetching, messages])
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? messages.length + 1 : messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 88,
measureElement: (element) => element.getBoundingClientRect().height,
})
const virtualItems = rowVirtualizer.getVirtualItems()
React.useEffect(() => {
const [firstItem] = virtualItems
if (!firstItem) return
if (firstItem.index === 0 && hasNextPage && !isFetching) {
fetchMoreMessages()
}
}, [virtualItems, hasNextPage, isFetching, fetchMoreMessages])
React.useEffect(() => {
if (virtualItems.length > 0 && !hasScrolledRef.current) {
const lastMessageIndex = hasNextPage ? messages.length : messages.length - 1
rowVirtualizer.scrollToIndex(lastMessageIndex, { align: 'end' })
hasScrolledRef.current = true
}
}, [virtualItems, rowVirtualizer, messages.length, hasNextPage])
return (
<div>
<h1>Livestream Chat Feed</h1>
<div ref={parentRef} className="chat-container">
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualItem) => {
const isLoaderRow = virtualItem.index === 0 && hasNextPage
const messageIndex = hasNextPage
? virtualItem.index - 1
: virtualItem.index
const message = messages[messageIndex]
return (
<div
key={isLoaderRow ? 'loader' : message?.id ?? virtualItem.index}
data-index={virtualItem.index}
ref={rowVirtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoaderRow ? (
<div className="chat-bubble" style={{ textAlign: 'center' }}>
<strong>Loading older messages...</strong>
</div>
) : message ? (
<div className="chat-bubble">
<strong>{message.author}:</strong>
<p>{message.message}</p>
</div>
) : null}
</div>
)
})}
</div>
</div>
</div>
)
}
export default App
With this in place, you get:

You started with a large list that would stall the browser and ended with a smooth, infinite-scrolling chat feed rendering thousands of dynamic rows.
The core pattern is:
overflow: autouseVirtualizer to render only visible rows into a sized inner containermeasureElement when your rows have dynamic heightsThis approach generalizes beyond chat. Any long list, feed, or grid in your frontend can use the same TanStack Virtual pattern to stay responsive, even at scale.

CI/CD isn’t optional anymore. Discover how automated builds and deployments prevent costly mistakes, speed up releases, and keep your software stable.

A quick comparison of five AI code review tools tested on the same codebase to see which ones truly catch bugs and surface real issues.

corner-shapeLearn about CSS’s corner-shape property and how to use it, as well as the more advanced side of border-radius and why it’s crucial to using corner-shape effectively.

An AI reality check, Prisma v7, and “caveman compression”: discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the November 26th issue.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now