How to speed up long lists with TanStack Virtual

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.

How to speed up long lists with TanStack Virtual

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:

  • Render thousands of messages
  • Support dynamic row heights (long vs. short messages)
  • Load older history as you scroll upward (inverted infinite scroll)

Project setup

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:

  1. @tanstack/react-virtual for virtualization
  2. @faker-js/faker for generating a large, realistic dataset

Install 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.

Defining the scroll container

Virtualization will not work unless the virtualizer knows the viewport it is responsible for. That means you must provide:

  • A scroll container with a fixed height
  • An overflow style (typically 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.

Generating a large dataset

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:

  • A shell UI
  • Basic styles
  • An allMessages array with 10,000 items

Next, you will see why you need virtualization.

Implementing the virtualizer

Step 1: Render everything (and break it)

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.

Step 2: Wire up useVirtualizer

Now 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:

  • How many items exist
  • Which element is scrollable
  • An initial height estimate for each row

Step 3: Render only virtual items

TanStack Virtual is headless. It gives you:

  1. A getTotalSize() function that returns the full list height (for the scrollbar)
  2. A getVirtualItems() array that describes the currently visible rows

To use this, you need two CSS rules:

  • A “sizer” <div> with position: relative and height set to getTotalSize()
  • Absolutely positioned children translated vertically using virtualItem.start

Here 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.

Handling dynamic row heights

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:

  1. A measureElement function passed to useVirtualizer
  2. A ref and data-index on each measured element

Update 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.

Virtualized chat feed showing dynamically sized chat bubbles

Adding chat-style infinite loading

The last step is to move from a static list to a realistic chat experience:

  • You only load the most recent messages at first
  • As the user scrolls upward, you fetch older history
  • On initial load, the view starts scrolled to the bottom

This involves three main changes:

  1. Treat allMessages as a database and keep a window of messages in state
  2. Simulate a “fetch more” API that prepends older messages
  3. Integrate a loader row and an initial scroll-to-bottom effect

Step 1: Message state and pagination metadata

Refactor 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
}

Step 2: Simulate an API to fetch older history

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.

Step 3: Integrate the virtualizer with loader rows

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()

Step 4: Trigger fetches when the user scrolls to the top

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])

Step 5: Scroll to the bottom on initial load

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])

Step 6: Render messages vs. loader rows

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:

  • A high-performance, virtualized list
  • Accurate dynamic row heights
  • Inverted infinite scroll that loads history as you scroll up
  • An initial scroll position anchored to the latest message

Chat feed with inverted infinite scroll and loading indicator

Conclusion

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:

  1. Give the virtualizer a fixed-height scroll container with overflow: auto
  2. Use useVirtualizer to render only visible rows into a sized inner container
  3. Use measureElement when your rows have dynamic heights
  4. Layer in pagination and loader rows for infinite scrolling

This 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.

The post How to speed up long lists with TanStack Virtual appeared first on LogRocket Blog.

 

This post first appeared on Read More