On this page
The bid queue renders up to five thousand packages with live fit scores, maps thumbnails, and status pills. Last month it ran at 4 frames per second on a mid-range laptop, and scrolling felt like dragging the page through mud. The fix was virtualization: render only the rows you can see. This is the full story, including the two traps that bit us.
Measuring first
Before touching code, we profiled. The breakdown was unambiguous:
- 78% of frame time in DOM layout and paint for off-screen rows
- 14% in fit-score recomputation on scroll
- 8% in everything else
Layout dominated because every row mounted a map thumbnail, three pills, and a sparkline — times five thousand. No amount of memoization fixes rendering five thousand of anything. The only fix is rendering fewer.
The virtualizer
The approach is standard but worth stating precisely. The list container keeps its full scroll height via a spacer element, and we mount only the rows intersecting the viewport plus a small overscan buffer:
const ROW_HEIGHT = 96; // fixed row geometry, measured once
const OVERSCAN = 6; // rows above and below the viewport
function visibleRange(scrollTop, viewportHeight, total) {
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const end = Math.min(total, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN);
return { start, end };
}
Fixed row geometry is the load-bearing decision. Variable heights require measuring every row, which reintroduces the layout cost we were escaping. We redesigned the row to a fixed 96 pixels instead — a better row and a faster list.
Trap one: scroll-linked recomputation
Our first version recomputed fit scores during scroll handling, which re-triggered renders mid-scroll and halved the gain. The fix: scores compute once per data change and are cached by package id; scroll handlers only slice the already-scored array. Scrolling is now pure DOM recycling — no business logic runs between frames.
Trap two: focus and keyboard navigation
Virtualized lists eat keyboard users if you let rows unmount beneath focus. We keep the focused row mounted regardless of scroll position and restore focus by package id after re-renders. The automated check is simple: tab through the queue with a screen reader and confirm every control announces. It passes, and we run it on every release.
The numbers
| Metric | Before | After |
|---|---|---|
| Scroll fps (mid-range laptop) | 4 | 60 |
| Mounted rows (5,000 total) | 5,000 | ~30 |
| Interaction to paint | 900ms | 60ms |
Do fewer things. It works every time.
More blog posts to read
Geocoding 40,000 well records without losing our minds
County plats, section-township-range grids, and hand-typed latitudes all disagree. Here is the pipeline that reconciles them into one map.
From PDF to structured bid in one pipeline
County bid packets arrive as scanned PDFs with no two alike. Our extraction pipeline turns them into structured packages.
Orphan-well intel, monthly
Contact sales