pura/blog
Home Blog Performance

Rendering 10,000 rows without freezing the browser

Large tables bring the interface to its knees when we try to paint everything at once. See how virtualization, intersection observers, and native Web Components keep 60 fps even with massive lists.

You have probably seen a table with thousands of records turn a smooth page into a slideshow. The framework is rarely to blame: it is the browser trying to build tens of thousands of DOM nodes, compute layout, and paint everything in a single frame. In this article we will break the problem into parts and solve each one with tools that already ship with the platform.

The giant DOM problem

Every cell that enters the tree costs memory and layout time. With 10,000 rows and six columns, that is 60,000 nodes in the table alone. The browser has to measure, position, and paint all of them, even the ones off screen. The user pays that cost in stutters.

The golden rule: the DOM should only contain what the eye can see. Everything below the fold is waste until the moment it becomes a pixel.

List virtualization

The core idea is to render only the visible window plus a few extra rows of slack. As scrolling advances, we recycle the same nodes and update their content instead of creating new elements. A spacer sets the total height so the scrollbar stays accurate.

Calculating the visible window

With a fixed row height, the math is straightforward: the first visible index is the scroll offset divided by the row height.

const ROW_H = 36; // fixed height of each row (px) const OVERSCAN = 6; // extra rows above/below function visibleRange(scrollTop, viewportH, total) { const start = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN); const count = Math.ceil(viewportH / ROW_H) + OVERSCAN * 2; const end = Math.min(total, start + count); return { start, end }; }

Recycling the nodes

Instead of removing and recreating rows, we keep a fixed pool and move each one with transform: translateY(). Transforms do not trigger reflow, only compositing, so scrolling stays smooth.

  • Reuse the nodes, do not destroy them on every frame.
  • Use translateY instead of top to position them.
  • Update content via textContent, avoiding innerHTML.

IntersectionObserver and scroll-spy

To react to what enters and leaves the screen without listening to every scroll event, IntersectionObserver is the right tool. It schedules the callbacks off the critical scrolling thread. The very <pura-scroll-spy> component in this article uses that API to highlight the index item on the right.

const io = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting) e.target.dataset.seen = "true"; } }, { rootMargin: "0px 0px -65% 0px" }); document.querySelectorAll("[data-row]").forEach((el) => io.observe(el));

Measuring for real

Optimization without measurement is guesswork. Before and after each change, open the Performance panel and look at the long frames. The goal is simple: no frame should exceed 16.6 ms.

  1. Record a profile while scrolling the list all the way to the bottom.
  2. Identify the long tasks flagged in red.
  3. Confirm that layout and paint shrank after virtualization.

Conclusion

Huge lists do not have to be slow. Render only what is visible, recycle nodes, delegate visibility detection to IntersectionObserver, and measure every step. The platform already delivers everything you need, without heavy dependencies.

Did this article help?