Large-Data Virtual Scroll

Understand how virtual scrolling renders rows around the current viewport and the constraints to consider when applying it to large datasets.

#virtual-scrolling#large-dataset#performance#dom-recycling#itemHeight#showLineNumber
Last reviewed: 2026-09-01
LIVE DEMOLarge-Data Virtual Scroll interactive example
Loading the interactive demo…
GitHub
import { t, exampleMsg } from './i18n';
import * as React from 'react';
import { notification } from 'antd';
import { BGrid, BGridColumn } from 'beautiful-grid';
import type { BGridCellSelectionCopyError } from 'beautiful-grid';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';

interface IOrderItem {
  orderNo: string;
  orderedAt: string;
  customerName: string;
  customerTier: string;
  salesChannel: string;
  region: string;
  salesTeam: string;
  salesRep: string;
  category: string;
  productName: string;
  sku: string;
  quantity: number;
  unitPrice: number;
  grossAmount: number;
  discountRate: number;
  netAmount: number;
  paymentMethod: string;
  paymentStatus: string;
  orderStatus: string;
  fulfillmentCenter: string;
  shippingMethod: string;
  promisedAt: string;
  riskLevel: string;
  marginRate: number;
}

const ROW_COUNT = 1000000;
const DAY_MS = 24 * 60 * 60 * 1000;
const ORDER_START_AT = Date.UTC(2026, 0, 1, 9);
const companyNames = [
  t('한빛리테일', 'Hanbit Retail'),
  t('서울커머스', 'Seoul Commerce'),
  t('오로라테크', 'Aurora Tech'),
  t('브릿지웍스', 'Bridge Works'),
  t('그린마켓', 'Green Market'),
  t('넥스트랩', 'Next Lab'),
  t('모노오피스', 'Mono Office'),
  t('클라우드나인', 'Cloud Nine'),
  t('페이퍼앤코', 'Paper & Co'),
  t('어반스토어', 'Urban Store'),
  t('블루하버', 'Blue Harbor'),
  t('트리니티솔루션', 'Trinity Solution'),
];
const customerTiers = [t('일반', 'General'), t('실버', 'Silver'), t('골드', 'Gold'), 'VIP'];
const salesChannels = [t('직영몰', 'Direct Mall'), t('오픈마켓', 'Open Market'), t('파트너', 'Partner'), t('오프라인', 'Offline'), 'B2B'];
const regions = [t('서울', 'Seoul'), t('경기', 'Gyeonggi'), t('인천', 'Incheon'), t('부산', 'Busan'), t('대전', 'Daejeon'), t('광주', 'Gwangju'), t('대구', 'Daegu'), t('제주', 'Jeju')];
const salesTeams = [t('엔터프라이즈 1팀', 'Enterprise Team 1'), t('엔터프라이즈 2팀', 'Enterprise Team 2'), t('커머스팀', 'Commerce Team'), t('파트너팀', 'Partner Team'), t('공공영업팀', 'Public Sales Team')];
const salesReps = [t('김하늘', 'Haneul Kim'), t('박민준', 'Minjun Park'), t('이서연', 'Seoyeon Lee'), t('최도윤', 'Doyun Choi'), t('정유진', 'Yujin Jeong'), t('한지민', 'Jimin Han'), t('윤서준', 'Seojun Yoon'), t('송지아', 'Jia Song'), t('임도현', 'Dohyun Lim'), t('강유나', 'Yuna Kang')];
const categories = [t('노트북', 'Laptop'), t('모니터', 'Monitor'), t('네트워크', 'Network'), t('스토리지', 'Storage'), t('소프트웨어', 'Software'), t('주변기기', 'Peripherals'), t('협업도구', 'Collaboration Tool'), t('보안', 'Security')];
const products = [
  t('AX 워크스테이션', 'AX Workstation'),
  t('UltraView 모니터', 'UltraView Monitor'),
  t('EdgeLink 라우터', 'EdgeLink Router'),
  'Vault NAS',
  'Workspace Pro',
  'Smart Dock',
  'Meeting Hub',
  'SecureKey',
  'Cloud Backup',
  'Analytics Seat',
  'WiFi 7 Access Point',
  'Ergo Keyboard',
];
const paymentMethods = [t('신용카드', 'Credit Card'), t('계좌이체', 'Account Transfer'), t('가상계좌', 'Virtual Account'), t('후불결제', 'Postpaid Payment'), t('법인카드', 'Corporate Card')];
const paymentStatuses = [t('결제 완료', 'Payment Completed'), t('입금 대기', 'Waiting for Deposit'), t('부분 결제', 'Partial Payment'), t('환불 완료', 'Refund Completed')];
const orderStatuses = [t('주문 접수', 'Order Receipt'), t('상품 준비', 'Product Preparation'), t('출고 완료', 'Dispatch Completed'), t('배송 중', 'In Transit'), t('배송 완료', 'Delivery Completed'), t('주문 취소', 'Order Cancelled')];
const fulfillmentCenters = [t('김포 FC', 'Gimpo FC'), t('용인 FC', 'Yongin FC'), t('이천 FC', 'Icheon FC'), t('대구 FC', 'Daegu FC'), t('부산 FC', 'Busan FC')];
const shippingMethods = [t('일반 택배', 'General Courier'), t('당일 배송', 'Same-day Delivery'), t('새벽 배송', 'Dawn Delivery'), t('화물 배송', 'Freight Delivery'), t('방문 수령', 'Pick Up in Store')];
const riskLevels = [t('낮음', 'Low'), t('관찰', 'Observation'), t('주의', 'Caution'), t('높음', 'High')];
const dateCache = new Map<number, string>();
function formatDateTime(timestamp: number) {
  let cached = dateCache.get(timestamp);
  if (!cached) {
    cached = new Date(timestamp).toISOString().replace('T', ' ').slice(0, 16);
    dateCache.set(timestamp, cached);
  }
  return cached;
}

const skuCache: string[] = [];
for (let i = 0; i < 18000; i++) {
  skuCache[i] = `SKU-${String(i).padStart(5, '0')}`;
}

const discountRates = [0, 3, 5, 7, 10, 15, 20];
const editions = ['Basic', 'Plus', 'Pro'];
const isEnglish = typeof document !== 'undefined' && document.documentElement.lang === 'en';

const list = new Array(ROW_COUNT);
for (let index = 0; index < ROW_COUNT; index++) {
  const sequence = index + 1;
  const quantity = ((index * 7) % 48) + 1;
  const unitPrice = 39000 + ((index * 7919) % 72) * 12500;
  const grossAmount = quantity * unitPrice;
  const discountRate = discountRates[index % 7];
  const orderedTimestamp = ORDER_START_AT + (index % 234) * DAY_MS + (index % 12) * 60 * 60 * 1000;
  const promisedTimestamp = orderedTimestamp + ((index % 6) + 1) * DAY_MS;

  list[index] = {
    values: {
      orderNo: `ORD-2026-${String(sequence).padStart(6, '0')}`,
      orderedAt: formatDateTime(orderedTimestamp),
      customerName: isEnglish
        ? `Branch ${index + 1}`
        : `${companyNames[index % companyNames.length]} ${regions[(index * 3) % regions.length]} ${(index % 37) + 1}호점`,
      customerTier: customerTiers[(index * 5) % customerTiers.length],
      salesChannel: salesChannels[(index * 7) % salesChannels.length],
      region: regions[(index * 3) % regions.length],
      salesTeam: salesTeams[(index * 11) % salesTeams.length],
      salesRep: salesReps[(index * 13) % salesReps.length],
      category: categories[(index * 5) % categories.length],
      productName: `${products[index % products.length]} ${2024 + (index % 3)} ${editions[index % 3]}`,
      sku: skuCache[(index * 17) % 18000],
      quantity,
      unitPrice,
      grossAmount,
      discountRate,
      netAmount: Math.round(grossAmount * (1 - discountRate / 100)),
      paymentMethod: paymentMethods[(index * 17) % paymentMethods.length],
      paymentStatus: paymentStatuses[(index * 19) % paymentStatuses.length],
      orderStatus: orderStatuses[(index * 23) % orderStatuses.length],
      fulfillmentCenter: fulfillmentCenters[(index * 29) % fulfillmentCenters.length],
      shippingMethod: shippingMethods[(index * 31) % shippingMethods.length],
      promisedAt: formatDateTime(promisedTimestamp),
      riskLevel: riskLevels[Math.min(Math.floor(((index * 37) % 100) / 25), riskLevels.length - 1)],
      marginRate: 12 + ((index * 41) % 31),
    },
  };
}

const columns: BGridColumn<IOrderItem>[] = [
    {
      id: 'orderNo',
      key: 'orderNo',
      label: t('주문 번호', 'Order Number'),
      width: 150,
      toolbox: true,
      filter: { type: 'text', caseSensitive: true },
    },
    {
      id: 'orderedAt',
      key: 'orderedAt',
      label: t('주문 일시', 'Order Date and Time'),
      width: 145,
      toolbox: true,
      filter: { type: 'text', caseSensitive: true },
    },
    { id: 'customerName', key: 'customerName', label: t('고객사', 'Client'), width: 210, toolbox: true, filter: { type: 'text' } },
    {
      id: 'customerTier',
      key: 'customerTier',
      label: t('고객 등급', 'Customer Level'),
      width: 100,
      toolbox: true,
      filter: { type: 'values' },
    },
    {
      id: 'salesChannel',
      key: 'salesChannel',
      label: t('판매 채널', 'Sales Channel'),
      width: 110,
      toolbox: true,
      filter: { type: 'values' },
    },
    { id: 'region', key: 'region', label: t('권역', 'Region'), width: 80, toolbox: true, filter: { type: 'values' } },
    { id: 'salesTeam', key: 'salesTeam', label: t('영업 조직', 'Sales Organization'), width: 135, toolbox: true, filter: { type: 'values' } },
    { id: 'salesRep', key: 'salesRep', label: t('담당자', 'Assignee'), width: 90, toolbox: true, filter: { type: 'values' } },
    { id: 'category', key: 'category', label: t('상품 분류', 'Product Category'), width: 105, toolbox: true, filter: { type: 'values' } },
    { id: 'productName', key: 'productName', label: t('상품명', 'Product Name'), width: 225, toolbox: true, filter: { type: 'text' } },
    {
      id: 'sku',
      key: 'sku',
      label: 'SKU',
      width: 115,
      toolbox: true,
      filter: { type: 'text', caseSensitive: true },
    },
    {
      id: 'quantity',
      key: 'quantity',
      label: t('수량', 'Quantity'),
      width: 75,
      align: 'right',
      toolbox: true,
      filter: { type: 'number' },
    },
    {
      id: 'unitPrice',
      key: 'unitPrice',
      label: t('단가', 'Unit Price'),
      width: 115,
      align: 'right',
      toolbox: true,
      filter: { type: 'number' },
      itemRender: ({ value }) => t(`${formatNumber(value)}원`, `${formatNumber(value)} KRW`),
    },
    {
      id: 'grossAmount',
      key: 'grossAmount',
      label: t('주문 금액', 'Order Amount'),
      width: 135,
      align: 'right',
      toolbox: true,
      filter: { type: 'number' },
      itemRender: ({ value }) => t(`${formatNumber(value)}원`, `${formatNumber(value)} KRW`),
    },
    {
      id: 'discountRate',
      key: 'discountRate',
      label: t('할인율', 'Discount Rate'),
      width: 85,
      align: 'right',
      toolbox: true,
      filter: { type: 'number' },
      itemRender: ({ value }) => `${value}%`,
    },
    {
      id: 'netAmount',
      key: 'netAmount',
      label: t('결제 금액', 'Payment Amount'),
      width: 135,
      align: 'right',
      toolbox: true,
      filter: { type: 'number' },
      itemRender: ({ value }) => t(`${formatNumber(value)}원`, `${formatNumber(value)} KRW`),
    },
    {
      id: 'paymentMethod',
      key: 'paymentMethod',
      label: t('결제 수단', 'Payment Method'),
      width: 110,
      toolbox: true,
      filter: { type: 'values' },
    },
    {
      id: 'paymentStatus',
      key: 'paymentStatus',
      label: t('결제 상태', 'Payment Status'),
      width: 110,
      toolbox: true,
      filter: { type: 'values' },
    },
    {
      id: 'orderStatus',
      key: 'orderStatus',
      label: t('주문 상태', 'Order Status'),
      width: 110,
      toolbox: true,
      filter: { type: 'values' },
    },
    {
      id: 'fulfillmentCenter',
      key: 'fulfillmentCenter',
      label: t('출고 센터', 'Dispatch Center'),
      width: 105,
      toolbox: true,
      filter: { type: 'values' },
    },
    {
      id: 'shippingMethod',
      key: 'shippingMethod',
      label: t('배송 방식', 'Delivery Method'),
      width: 110,
      toolbox: true,
      filter: { type: 'values' },
    },
    {
      id: 'promisedAt',
      key: 'promisedAt',
      label: t('출고 예정일', 'Expected Dispatch Date'),
      width: 145,
      toolbox: true,
      filter: { type: 'text', caseSensitive: true },
    },
    { id: 'riskLevel', key: 'riskLevel', label: t('거래 위험도', 'Transaction Risk Level'), width: 105, toolbox: true, filter: { type: 'values' } },
    {
      id: 'marginRate',
      key: 'marginRate',
      label: t('매출 총이익률', 'Gross Margin'),
      width: 120,
      align: 'right',
      toolbox: true,
      filter: { type: 'number' },
      itemRender: ({ value }) => `${value}%`,
    },
];

const virtualScrollColumns = columns.map(column => ({
  ...column,
  toolbox: false as const,
  filter: false as const,
}));

function ScrollExample() {
  const [notificationApi, contextHolder] = notification.useNotification();

  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width: containerWidth, height: containerHeight } = useContainerSize(containerRef);
  const handleCopyError = React.useCallback(
    (error: BGridCellSelectionCopyError) => {
      const description =
        error.reason === 'maxClipboardCells'
          ? `${t('선택된 셀이', 'Selected cells are')} ${formatNumber(error.actual)}${t('개입니다. 최대', 'ea. Up to')} ${formatNumber(error.limit)}${t('개까지만 복사할 수 있습니다.', 'ea can be copied.')}`
          : error.reason === 'maxClipboardTextLength'
          ? `${t('복사할 텍스트가', 'Text to copy is')} ${formatNumber(error.actual)}${t('자입니다. 최대', 'characters long. Up to')} ${formatNumber(error.limit)}${t('자까지만 복사할 수 있습니다.', 'characters can be copied.')}`
          : t('브라우저가 클립보드 복사를 거부했습니다.', 'The browser denied copying to the clipboard.');

      notificationApi.warning({
        message: t('복사할 수 없습니다', 'Cannot copy'),
        description,
        placement: 'topRight',
      });
    },
    [notificationApi],
  );

  return (
    <DataGridContainer ref={containerRef}>
      {contextHolder}
      <BGrid<IOrderItem>
        width={containerWidth}
        height={containerHeight}
        data={list}
        columns={virtualScrollColumns}
        msg={exampleMsg}
        rowKey='orderNo'
        showLineNumber
        cellSelectionOptions={{
          onCopyError: handleCopyError,
        }}
        rowChecked={{
          checkedIndexes: [],
          onChange: (ids, selectedAll) => {
            console.log('onChange rowSelection', ids, selectedAll);
          },
        }}
        onClick={item => console.log(item)}
        status={{
          content: ({ visibleItems }) => `${t('현재', 'Current')} ${formatNumber(visibleItems)}${t('건 / 전체', 'cases / Total')} ${formatNumber(ROW_COUNT)}${t('건', 'cases')}`,
        }}
        pagination={{ visible: false }}
      />
    </DataGridContainer>
  );
}

function formatNumber(value?: number) {
  return value === undefined ? '-' : value.toLocaleString();
}

export default ScrollExample;

1. Why virtual scrolling is essential

What happens if a standard HTML <table> renders more than 10,000 <tr> elements at once?

  1. Browser freezes: Creating and calculating tens of thousands of DOM nodes can block the main thread for several seconds.
  2. Excessive memory use: The memory allocated to all those DOM nodes can crash the tab.
  3. Scroll jank: Recalculating tens of thousands of layouts during scrolling causes dropped frames.

BeautifulGrid’s virtual-scrolling logic uses height, itemHeight, and the scroll position to calculate and render a range of rows around the current viewport. The number of rendered rows and perceived performance depend on the grid height, cell-renderer complexity, and browser environment.


2. How the virtual-scroll range is calculated

When a scroll event occurs, BeautifulGrid calculates the render range with the following formulas in O(1) time:

The live demo above uses the default 29 px total row height: itemHeight of 15 px plus 7 px of itemPadding above and below. The logical height of 1,000,000 rows is 29,000,000 px, while the default modern scrollbar caps the actual DOM scroll height at 1,000,000 px. BeautifulGrid combines the current physical window with a logical base offset, so the row-number column can still reach row 1,000,000.

When the physical scroll position approaches either edge of its safe window, BeautifulGrid moves the base offset and compensates scrollTop in the opposite direction. Their sum—the logical scrollTop—does not change, so wheel and trackpad movement remain continuous. The custom scrollbar thumb uses the complete logical height and can be dragged directly from the first row to the last. Explicit scrollbar.variant="native" keeps the browser’s native scroll range for backward compatibility; use modern or classic for massive datasets that may exceed browser layout limits.

1. Logical position: logicalScrollTop = virtualBase + physicalScrollTop
2. Start index: startIndex = Math.floor(logicalScrollTop / rowHeight)
3. End index: endIndex = startIndex + displayItemCount + buffer
4. Physical render position: renderTop = startIndex * rowHeight - virtualBase
5. Near an edge, move virtualBase and physicalScrollTop by equal amounts in opposite directions

Virtualization reduces the number of DOM rows, but the complete source data passed to the grid still remains in memory. The logical-scroll coordinate mapping is verified for 10,000,000 rows (290,000,000 px at the default row height). However, this does not guarantee that a browser can retain ten million row objects in memory. The practical maximum of the current API depends on row payload size and the browser’s available heap. Production systems with several million rows should use server queries, pagination, or a remote data source. Measure initial data-generation and transfer costs, cell-renderer cost, and sorting and filtering costs separately.


3. Complete example: a 10,000-record transaction-log viewer

The following example generates 10,000 log records and applies sorting and filtering across the complete virtualized dataset:

import React, { useCallback, useMemo, useState, useTransition } from 'react';
import {
  BGrid,
  type BGridColumn,
  type BGridDataItem,
  type BGridDataQuery,
} from 'beautiful-grid';

interface LogItem {
  id: number;
  timestamp: string;
  level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
  service: string;
  message: string;
  latencyMs: number;
}

export default function LargeLogViewer() {
  const [isQueryPending, startQueryTransition] = useTransition();
  const [query, setQuery] = useState<BGridDataQuery>({ sortParams: [], filterParams: [] });
  const handleQueryChange = useCallback((nextQuery: BGridDataQuery) => {
    startQueryTransition(() => setQuery(nextQuery));
  }, [startQueryTransition]);

  // 1. Generate 10,000 mock records
  const data: BGridDataItem<LogItem>[] = useMemo(() => {
    const levels: LogItem['level'][] = ['INFO', 'WARN', 'ERROR', 'DEBUG'];
    const services = ['auth-service', 'order-api', 'payment-gateway', 'notification-worker'];

    return Array.from({ length: 10000 }).map((_, i) => ({
      values: {
        id: i + 1,
        timestamp: new Date(Date.now() - (10000 - i) * 1000).toISOString().replace('T', ' ').substring(0, 19),
        level: levels[i % levels.length],
        service: services[i % services.length],
        message: `Request processed for user_session_${1000 + (i % 500)} with HTTP 200 OK`,
        latencyMs: Math.floor(Math.random() * 450) + 10,
      },
    }));
  }, []);

  // 2. Configure columns
  const columns: BGridColumn<LogItem>[] = [
    { id: 'id', key: 'id', label: 'Log ID', width: 90, align: 'center', toolbox: true, filter: { type: 'number' } },
    { id: 'timestamp', key: 'timestamp', label: 'Timestamp', width: 170, align: 'center', toolbox: true, filter: { type: 'text' } },
    {
      id: 'level',
      key: 'level',
      label: 'Level',
      width: 90,
      align: 'center',
      toolbox: true,
      filter: { type: 'values' },
      itemRender: ({ values }) => {
        const colors = {
          INFO: '#2563eb',
          WARN: '#d97706',
          ERROR: '#dc2626',
          DEBUG: '#64748b',
        };
        return (
          <span style={{ fontWeight: 700, color: colors[values.level] }}>
            {values.level}
          </span>
        );
      },
    },
    { id: 'service', key: 'service', label: 'Service Name', width: 160, toolbox: true, filter: { type: 'values' } },
    { id: 'message', key: 'message', label: 'Log Message', width: 380, toolbox: true, filter: { type: 'text' } },
    {
      id: 'latencyMs',
      key: 'latencyMs',
      label: 'Response Time (ms)',
      width: 120,
      align: 'right',
      toolbox: true,
      filter: { type: 'number' },
      itemRender: ({ values }) => (
        <span style={{ color: values.latencyMs > 300 ? '#dc2626' : '#16a34a', fontWeight: 600 }}>
          {values.latencyMs} ms
        </span>
      ),
    },
  ];

  return (
    <div>
      <div style={{ marginBottom: 12, fontSize: 14, color: '#475569' }}>
        <strong>{data.length.toLocaleString()}</strong> real-time log records are loaded with virtual scrolling.
      </div>

      <BGrid<LogItem>
        width={850}
        height={450} // Viewport height fixed (required)
        columns={columns}
        data={data}
        rowKey="id"
        showLineNumber // Verify the virtual scroll location and the full data range
        dataControl={{
          mode: 'client',
          multiSort: true,
          query,
          onChange: handleQueryChange,
        }}
        spinning={isQueryPending}
        itemHeight={28} // Explicit row height (25–28 px is recommended)
        headerHeight={34}
      />
    </div>
  );
}

4. Practical optimization tips for high-performance rendering

1) Avoid expensive calculations and hook calls inside itemRender

Rows in the viewport rerender rapidly as the virtual-scroll position changes. Keep itemRender focused on simple formatting; avoid expensive regular-expression parsing, filtering large arrays, and creating many new objects.

2) Set itemHeight to match the row content exactly

If the actual row height differs from itemHeight, the grid can appear to jump slightly while scrolling. Set an explicit height that matches the design, such as itemHeight={28} or itemHeight={32}.

3) Detect parent-container size changes (useContainerSize)

For dashboards that fill the available screen, measure the container instead of using fixed pixel dimensions. Pass the measured width and height to the grid so the virtual-scroll range is recalculated smoothly when the window changes size.

4) Move sorting and filtering for 1,000,000 rows to the server

The live demo focuses on verifying consistent virtual-scroll positions across 1,000,000 rows. Sorting or filtering that many rows in the browser can severely reduce UI responsiveness. In production, use dataControl.mode: 'manual' with server-side queries or pagination. See Sorting and Filtering Toolbox for client-side sorting and filtering on smaller datasets.


5. Frequently asked questions

Q. How does browser search (Ctrl+F) work with virtual scrolling?

Only the rows currently visible in the viewport exist in the DOM, so the browser’s built-in Ctrl+F cannot find off-screen data. For large datasets, provide a grid-specific filter with the Sorting and Filtering Toolbox.