JavaScript / Performance / Frontend

On Rendering Complex Tables

A practical look at virtualizing large tables to keep rendering fast and responsive.

Building a dashboard means building tables, lots and lots of it.

at first we might start with small table, but then along the time we would start encountering a big table that doesn’t make sense to be paginated.

We might also encounter a table that need to be infinitely loaded, expanded, or even there we be table inside a table that can be infintely loaded.

ok maybe now I already convinced you about this table problem. I first encounter this issue when seeing that a table that has polling mechanism to get the data start consuming high CPU during polling, and blocking the main thread. The INP for that table become really really worse.

The initial solution we grab is usually to do virtualization rendering. fortunately there is already great table library called Tanstack Virtual and it work great on most case, sometime though, there might be still case of blanking

10,000 rows, about 20 mountedDOM updates are deliberately delayed by 180 ms.
Preparing the virtual table...
IDMerchantAmount

Drag the scrollbar thumb quickly or use the jump button. The browser scrolls immediately, while JavaScript commits the next TanStack range later.

we usually solve this by calculating what amount of to the height of the table on the top and bottom of it. for example if the table is 500px, then we add 500px above and below overscan

Full table height
Unmounted rows
500 px top overscanmounted, outside the viewport
Browser viewport
#0321Merchant 46$8,240
#0322Merchant 12$1,940
#0323Merchant 89$4,120
500 px bottom overscanmounted, outside the viewport
Unmounted rows
The virtualizer mounts the viewport plus a buffer on both sides. Everything beyond that range remains represented by empty height, not DOM rows.
Overscan laboratory500px buffer = 14 extra rows on each side
IDMerchantAmount

For a simple table especially if the tables are this overscan already does wonder but it can has issue with a few case. the first case would be if we have complex table, lets say the table has 15 Column, the first column a checkbox column that have select-all feature and some column like Status has chip, and some id Columns are truncated and has tooltip and also a copy button.

Complex table: 2,000 parent rows x 13 columns0 rows mounted | 0 cells plus controls and tooltips
0 of 2,000 rows selected
Transaction IDMerchantReferenceStatusChannelMethodCurrencyCountryRiskCustomer IDCreatedUpdated

Drag the scrollbar thumb quickly. Every mounted row keeps three tooltip trees, three copy controls, two chips, and a checkbox alive while the virtualizer replaces the visible range. Any green flash is the row underlay showing through.

now when you scroll normally you won’t see any flash. but what about if you go very fast? or if your mouse scroll doesn’t allow to go super fast, try dragging with the scroll handle, like the use case is if you want to go to the very bottom. do you see the green flash? I intentionally make the tbody green so the flashing is more obvious for illustration but you get the point

you might think, but is 2000 to extreme of a case, but this demo is also simpler, like all the data is already in frontend and we don’t do any formatting, checking, parsing, etc. also the table could be more complex than this or having more column.

but even without all that, lets just for the sake of experiment turned it down to 250 rows

Complex table: 250 parent rows x 13 columns0 rows mounted | 0 cells plus controls and tooltips
0 of 250 rows selected
Transaction IDMerchantReferenceStatusChannelMethodCurrencyCountryRiskCustomer IDCreatedUpdated

Drag the scrollbar thumb quickly. Every mounted row keeps three tooltip trees, three copy controls, two chips, and a checkbox alive while the virtualizer replaces the visible range. Any green flash is the row underlay showing through.

drag the scroll handle up and down and you can see a green flash.

Problem

so what is the problem really?

lets start by take a look what happened when we scroll the virtualized table in react

What JavaScript and React do after a scroll eventThis entire path runs before updated rows are ready to display.
1
Scroll event reaches JavaScriptThe handler receives the latest scroll position.
2
TanStack reads scrollTopThe virtualizer learns where the viewport moved.
3
Calculate visible rows and overscanChoose the row indexes that should now exist in the DOM.
4
React renders the mounted rowsRun every row and cell component, including checkboxes, chips, tooltips, copy controls, and formatting.
5
React reconciles and commitsCompare trees, remove old rows, and update the DOM.
6
Browser performs style, layout, and paintThe new DOM finally becomes updated pixels.
The cost is the complete path, not one row in isolation. More mounted rows, columns, and cell features increase the work in steps 4 through 6.

so yeah virtualizing table doesn’t really come in free, because we need to calculate how far is the current scroll level from the top to decide the current viewport of that table.

after we know the top offset, React need to render the rows in the viewport plus the above and below overscan to then finally commit and update the DOM. because React also render the overscan so just adding bigger overscan does not guarantee to fix this if it make the work take longer time, like if the table is complex.

so what happened if all this work take longer than the the time for the viewport to move beyond the mounted + overscan range? yep, we get the flashing thing.

This happened because rendering complex rows can block the main thread, that is why this doesn’t happened when the table is simply contain text. the more complex the component we have, the more time it will take. the timeline illustrated better below

One fast scrollbar drag during a polling updateA frame has roughly 16 ms; the main-thread task below takes 72 ms.
CompositorDrag inputViewport keeps moving and painting
Main threadLong task: parse poll response -> replace rows -> React render -> layoutCommit rows
Event queueScroll handler is ready, but must wait for the current taskRun handler
Frame deadlinesMissedMissedMissedMissedRows ready
Viewport can expose the empty green scroll surface during this gap
The scroll event is not lost. It is queued behind the long task. Native composited scrolling can still advance, so the viewport moves before the virtualizer's JavaScript gets a chance to calculate and commit the next rows.
Browser compositor moves the viewport immediatelywhileJavaScript is still preparing the next rows
10 ms Before scrolling
Rendered row window
Viewport

The mounted rows cover everything the reader can see.

216 ms Browser moved first
Old rows are still here
Blank viewportJavaScript busy

The viewport reaches green space before React commits its next range.

3Later JavaScript catches up
New row window
Viewport

The new rows cover the viewport, so the green flash disappears.

The scrollbar represents the full estimated table height, but only the row window exists in the DOM. Blanking is the moment the viewport moves beyond that window before JavaScript can move it too.

If your table is all text, there is a technique where you skip React and updating the textContent node directly, reusing the same DOM

solution

this too long, get to the solution already! okay okay. So how do we fix this ? there is a three step to this

Calculating Offset

A virtualizer needs a layout model to map the browser’s current scroll offset to the rows that should be rendered.

lets say we cap the row height to 40px, then 1000 rows, just means the total area is 40000px, nice and easy

but this breaks when there is few complexity:

  1. we want to support wrapping in the table
  2. we want to support row with different size
  3. we want to support some interaction like expanding the table

for this Tanstack Table expose the two api to measure and recalculate. which is measureElement and resizeItem

For dense tables, wrapping and variable normal-row heights are usually avoidable. We usually prefer to truncate the content and adding some tooltip. performance wise wrapped table potentially make the recalculation happened as user scroll since the height is no longer fixed

TanStack Virtual provides two APIs that we can use for recalculation to create the use case 3 for expanding row:

  • measureElement measures a mounted item and observes later size changes.
  • resizeItem(index, size) sets an item’s size directly when the application already knows it.
function ExpandableVirtualTable({ rows }: { rows: Row[] }) {
  // ...setup
  return (
    <div ref={scrollRef} style={{ height: 400, overflow: "auto" }}>
      <div
        style={{
          height: virtualizer.getTotalSize(),
          position: "relative",
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const item = flatItems[virtualItem.index];
          if (!item) return null;

          return (
            <div
              key={virtualItem.key}
              data-index={virtualItem.index}
              // Normal rows are exactly 40px. Only unknown expansion-panel
              // heights are measured and observed for later size changes.
              ref={
                item.kind === "details"
                  ? virtualizer.measureElement
                  : undefined
              }
              style={{
                position: "absolute",
                transform: `translateY(${virtualItem.start}px)`,
                width: "100%",
                height: item.kind === "row" ? 40 : undefined,
              }}
            >
              {item.kind === "row" ? (
                <button onClick={() => toggleExpanded(item.row.id)}>
                  {expanded.has(item.row.id) ? "Collapse" : "Expand"}{" "}
                  {item.row.name}
                </button>
              ) : (
                <LargeDetails row={item.row} />
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

Inverse sticky technique

This technique comes from Pierre’s article, On Rendering Diffs.

and I am merely copying and adapting it to be used on table. I really recommend you to read the article, it is really interesting.

here is how it works from the article itself

… we invert the usual sticky behavior. Instead of pinning the top of the rendered content to the top of the viewport as you scroll down, the bottom edge of the rendered region sticks to the bottom of the viewport when you scroll past it. When you scroll back up, the top edge sticks to the top of the viewport.

… This gives us native scrolling while the viewport is inside the rendered range. If JavaScript falls behind, the rendered region sticks to one edge instead of scrolling away and exposing blank space. …

This decouples native browser scrolling from JavaScript’s range calculation/rendering. Without the sticky technique, if JavaScript takes too long to calculate and render the next range, the browser can scroll past the currently rendered content and expose blank space. By making the rendered content sticky, it stays pinned to the edge of the viewport until JavaScript catches up, greatly reducing blanking.

Make JavaScript fall behindBoth lists update continuously with 128px of overscan on each side.
Ordinary virtual windowRendered rows scroll away
#0001Payment 1Rendered
#0002Payment 2Rendered
#0003Payment 3Rendered
#0004Payment 4Rendered
#0005Payment 5Rendered
#0006Payment 6Rendered
#0007Payment 7Rendered
#0008Payment 8Rendered
#0009Payment 9Rendered
#0010Payment 10Rendered
#0011Payment 11Rendered
#0012Payment 12Rendered
Inverse-sticky windowRendered rows pin to the viewport edge
#0001Payment 1Rendered
#0002Payment 2Rendered
#0003Payment 3Rendered
#0004Payment 4Rendered
#0005Payment 5Rendered
#0006Payment 6Rendered
#0007Payment 7Rendered
#0008Payment 8Rendered
#0009Payment 9Rendered
#0010Payment 10Rendered
#0011Payment 11Rendered
#0012Payment 12Rendered

Drag either scrollbar or use the jump button. Green is the empty spacer: if scrolling outruns React, the sticky list keeps the last committed range pinned to the viewport edge.

this demo intentionally use small overscan per side to make blanking and the inverse sticky scrolling easier to observe

DOM pooling

Virtualization make us only render mounted DOM. but it can still make the us churn a lot of work during agressive scroll because a typical virtualizer removes rows that leave the rendered range and mounts new ones in their place. In our Dashboard, each row can contain checkboxes, chips, tooltips, copy buttons, event handlers, and many table cells. Recreating those elements repeatedly produces allocations that eventually need to be cleaned up, potentially adding garbage-collection pauses to the scrolling path.

To reduce that work, we treats the mounted rows as a pool of reusable slots. If the virtualizer needs 30 rows, We creates approximately 30 physical row and cell shells. As the rendered range changes, each slot is reassigned to another record instead of destroying its DOM and creating a replacement. The slot receives the new row index, position, height, values, and event handlers.

Reusing a row safely requires separating its physical shell from its record-specific state. so we keys the row shell by its pool slot, but keys stateful cell content by the record’s stable identity. When a slot changes from one record to another, React preserves the <tr> and <td> elements while resetting consumer state such as copied indicators, uncontrolled inputs, and other item-specific UI.

This make the table has a pool of reusable rows and cells shells, and only destroying and recreating the content of the column, not the whole rows.

now lets combine all the our approach to re-tackle our complex table:

Pooled inverse sticky0 physical row slots | 492px overscan per side
Rows: 2,000Columns: 13Pool: 0 reusable slotsSettled
Transaction IDMerchantReferenceStatusChannelMethodCurrencyCountryRiskCustomer IDCreatedUpdated

The row and cell shells are keyed by pool slot, while stateful cell content is keyed by record identity. The opaque underlay and one-row sticky overlap keep the pooled window covered while those slots are rebound.

Conclusion

This has been a very cool experimentation. but even though I feel like this is just me standing on the shoulder of giants, I just reuse the Virtual component from Tanstack and the inverse technique from Pierre’s. but at least am really happy with the result. by using this technique at work the memory for our complex tables reduced by more than 50%, and there is no blank anymore when scrolling !