Virtualizing a list is easy. Making it feel like native scroll is where everything breaks.
The basic version is easy enough: take a long list, render only the items near the viewport, and fake the rest with spacer height. That gets you from “thousands of DOM nodes” to “a few dozen DOM nodes,” which is already a huge win.
But a real feed isn’t a uniform stack of rows. It’s a constantly shifting mix of text, threads, media, and loading states–most of which don’t even know their final height when they first render. You’re not just rendering less. You’re trying to simulate a stable surface on top of something inherently unstable.
Dynamic heights
Every virtualizer needs to know how tall each row is. If every row is 67px, life is beautiful. You can compute positions with simple multiplication and go home.
Our feed rows are not like that.
A thread can have a few words or a few paragraphs. It can include a summary. It can include a quote. It can have replies. It can have media, files, links, or no attachments at all. It can render differently depending on whether it is summarized. It can change height depending on the width of the feed pane.
The number of possible shapes gets large very quickly. Even a relatively simple estimator has hundreds of meaningful combinations once you include text length buckets, attachment types, summaries, quotes, replies, separators, and responsive width.
So we start with a prediction.
Each row gets an estimated height based on its content shape. If we know the exact height from a previous render, we use that. If not, we make an educated guess: text length, attachment presence, attachment kind, summary state, reply count, and surrounding row structure all feed into the estimate.
The estimator does not need to be perfect. It just needs to be close enough that the virtualizer can build a plausible scroll space before the real DOM exists.
Measure, cache, repeat
Once a row actually renders, we measure its real height and cache it.
That cache is keyed by more than just the row ID. The same thread can have a different height in different contexts, and width matters a lot. A paragraph that takes four lines in a narrow pane might take two lines in a wide one. So the cache includes the render mode and a width bucket.
That gives the feed a feedback loop:
- Predict the row height.
- Render the row when needed.
- Measure the actual height.
- Cache the result.
- Use the real number next time.
Over time, the feed gets better at itself. Estimates are replaced by measurements. Measurements survive remounts. Previously seen rows become cheap and stable.
But we can do better than waiting until a row enters the viewport.
Just-in-time height discovery
The nice trick is to use idle time.
When the browser is not busy, we render a small batch of upcoming thread rows off-screen, measure them, and cache their exact heights before the user reaches them.
Not hundreds of rows. Not the whole feed. Just enough.
If the virtual window is only rendering 10 to 20 visible-ish items at a time, then measuring a small batch ahead of the viewport works surprisingly well. It turns unknown future rows into known rows just in time.
This means the virtualizer often receives real measurements before those rows become visible. The user gets the benefit of exact layout without paying the cost of rendering the entire feed upfront.
The important constraint is that this happens off the critical path. Scroll stays responsive. Idle measurement is opportunistic. If the browser is busy, scrolling wins.
Windowing
We use a headless virtualizer, @tanstack/react-virtual, for the core windowing logic. That gives us the machinery for computing which items should exist, where they should be positioned, how large the total scroll area should be, and how measurements update the model.
That part is valuable because it lets us avoid rebuilding a solved problem.
But the product-specific logic lives around the virtualizer.
The virtualizer can tell us, “these are the rows near the viewport.”
It does not know that our feed is bottom-oriented. It does not know that new content should keep you pinned only if you were already near the latest item. It does not know that older rows are loaded by prepending content above the current viewport. It does not know that some browser engines like WebKit can get weird if you mutate scroll position during momentum scrolling.
Keeping the viewport stable
Feeds grow in both directions. New content arrives near the bottom. Older content loads above the viewport when you scroll upward. Both cases create the same underlying risk: the list changes, and the user's visual position doesn't survive it.
Prepending is where most virtualized feeds fall apart first. Imagine you're reading something near the top of the loaded list. You scroll up, older rows load, and the virtual space above the viewport expands–the top spacer grows to account for the new rows now sitting outside the rendered window. Without compensation, that expansion shifts your scroll position and the content appears to jump.
To prevent that, we track an anchor–a visible row's stable key and its offset from the top of the viewport. When the spacer above grows, we find that same row again and nudge scrollTop to match the new offset. The virtual space changed. The visual position doesn't.
The bottom edge has its own version of the same problem. If you're already at the latest content and a new item arrives, the feed should keep you there. But if you've scrolled up to read history, it shouldn't yank you back down.
So the feed tracks whether you're near the bottom. If you are, new rows keep the bottom edge fixed. If you've scrolled up intentionally, that pin releases, and new content arrives quietly below without pulling the viewport along.
Compatibility
Then there is WebKit and mobile browsers.
Touch scrolling, momentum, rubber-banding, and programmatic scroll position changes behave differently across browsers- and the gaps matter. What feels invisible on Chrome can feel broken on Safari. What works cleanly on desktop can jitter on mobile. A scroll correction that lands perfectly during a mouse wheel event can fight a touch gesture on a different engine entirely.
So scroll correction has to be timing-aware, and that timing has to account for the environment it's running in.
During active touch interaction, we defer nonessential scroll mutations. After touch ends, we wait briefly for momentum to settle. Once the scroll state is calm, measurement corrections can resume. We also disable the browser's native scroll anchoring where it conflicts with our own, while keeping native momentum scrolling intact, because replacing that is a losing battle.
The specific bugs vary by browser. The solution is the same everywhere: a small pile of careful accounting.
Track touch start and end separately from scroll events. Detect intentional upward movement to release bottom pinning. Avoid scroll mutations during active touch. Delay corrections briefly after touch release. None of that is glamorous, but it is the kind of cross-browser detail that determines whether the feed feels native or just slightly, inexplicably wrong.
The data has to be ready too
Rendering is only half the story.
A virtualized feed can still feel slow if every row wakes up and immediately starts fetching its own data. You would save DOM nodes, but trade that for a burst of per-row database reads right as items enter the viewport.
So the feed does the opposite: it fetches thread listings as hydrated pages.
When the feed asks for a page of rows, it does not just get IDs back. It gets enough thread data to render the passive feed view: the focused entry, its visible thread chain, attachments, quoted entries, list associations, summaries, and the metadata needed by the estimator.
That means the first render of a virtual row is mostly working from already-shaped data. The component is not discovering what it is after it mounts; it is handed a prepared snapshot.
Under the hood, those reads are cached in two useful ways.
First, listing pages are cached by scope, like the current month or list. If you leave a feed view and come back, the UI can restore the previous page of hydrated threads immediately while a refresh catches up in the background.
Second, individual thread and entry reads are cached below the component layer. The cache stores resolved reads, deduplicates in-flight reads, and invalidates when entries or attachments change. So if multiple components ask for the same thread at the same time, they do not stampede the database. They share the same pending work.
This matters because virtualization constantly mounts and unmounts components. Without a data cache, scrolling can accidentally turn into repeated data fetching. With the cache, remounting a row is cheap: the visual shell, the estimated height, and the hydrated thread data are all likely already available.
So the feed has two feedback loops running together:
Layout loop: estimate height, render, measure, cache
Data loop: hydrate page data, seed row components, reuse cached reads, invalidate on writes
That combination is what lets a row appear quickly when it enters the virtual window. The renderer is not doing all the work at the last possible moment. A lot of the expensive stuff has already been done just ahead of time, or cached from the last time the row existed.
None of this is one technique. It's a system where each piece covers for the others.
Estimation gives the virtualizer enough to work with before anything renders. Measurement replaces those guesses with reality. Caching means that expensive data fetching survives remounts and doesn't have to be rediscovered. Idle pre-measurement moves the expensive work off the critical path. Anchor compensation keeps the user looking at the same content even as the list changes around them. Browser-aware scroll handling keeps corrections from becoming interference.
Remove any one of those and something breaks. Not catastrophically, but in that subtle, hard-to-name way that will make it feel unpolished. A small jump on prepend. A jitter during momentum scroll. A row that takes just a beat too long to settle.
The gap between a virtualized feed and a feed that feels right is almost entirely in that second category of problems. Rendering fewer rows is the easy part. The actual work is everything that keeps the user from ever noticing you did it.