Live Streaming

Live updates over a virtualized market feed
A simulated market feed over 5,000 symbols. Prices are kept only for rows that entered the viewport, and every tick mutates only the visible window - the per-tick cost stays constant however large the dataset grows. Pause, resume, or change the feed speed.

How to build a feature like this

Dashboards, market feeds, and log tails show a steady stream of data over a stable set of rows. When updates mutate values in place - no rows are inserted or removed, and every row keeps its size - the list has a decisive property for live refresh: the engine's geometry never changes, so an update cannot shift the layout or move the user's scroll position. The technique is to keep that geometry fixed and drive each tick from the visible range the engine reports, touching only mounted rows so the per-tick cost is O(viewport) no matter how large the dataset is. The tradeoff is architectural: you design for a fixed set of uniform-height rows that refresh in place, which is the right fit for live values but not for an ever-growing tail (that case needs the end-anchored append pattern instead).

1. Model rows for in-place updates

Two models fit a live feed. The mainstream one is a real array of row objects: every tick mutates a field (such as price) on existing items, reactivity re-renders the mounted rows, and a uniform numeric item-size keeps layout O(1) with no DOM measurement. Pass items the reactive array and read each row from the #item slot's item, as in the snippet below.

If the dataset is huge, or each row's payload is a pure function of its index so storing every row is wasteful, use an index-only list instead: a sparse placeholder as items (new Array(count)), content derived inside the slot from index, and - when rows carry state - a reactive store keyed by index (a Map) that materializes a value only when the row first enters the viewport. The VirtualScroll props are identical; only the slot differs (item vs index). Reach for real objects when you already own the data, and index-only when deriving each row is inexpensive and you want zero storage cost for the unseen rows.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. The overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.

<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
import { reactive } from 'vue';

// Mainstream model: a real array of row objects. All rows share one fixed
// height, so a numeric item-size keeps layout O(1) with no DOM measurement.
const rows = reactive(
  Array.from({ length: 5_000 }, (_, index) => ({ id: index, price: 100 })),
);
</script>

<template>
  <VirtualScroll
    virtual-scrollbar
    ref="feed"
    class="feed"
    :items="rows"
    :item-size="44"
    :buffer-before="6"
    :buffer-after="6"
    @scroll="onScroll"
  >
    <template #item="{ item }">
      <div class="row">{{ item.id }} - ${{ item.price.toFixed(2) }}</div>
    </template>
  </VirtualScroll>
</template>

<style scoped>
.feed {
  height: 480px;
}
.row {
  box-sizing: border-box;
  height: 44px; /* must equal item-size */
  display: flex;
  align-items: center;
  padding-inline: 12px;
  font-variant-numeric: tabular-nums; /* digits keep a stable width */
}
</style>

2. Keep the geometry fixed - that is what preserves the scroll

For updates not to jump the viewport, the list's geometry must stay constant: pass an item-size that equals the rendered row height, and keep value text from reflowing its row or column - fixed widths plus font-variant-numeric: tabular-nums so a price change does not change digit widths. Because the row count and every row's size are constant, the total content height never changes, so the browser keeps a valid scrollTop and you need no anchoring or restoration code for in-place updates.

This is the key contrast with an appending list: when you push rows onto the end the content height grows, and keeping the newest row visible requires anchoring to the last index after each append (an end-aligned scrollToIndex) - a separate mechanism from the in-place refresh shown here.

3. Drive each tick from the reported visible range

The @scroll event emits a ScrollDetails whose range field ({ start, end }) is the window of mounted rows (buffers included). Cache it, and on every tick mutate only [start − k, end + k], with a small k overscan so rows about to scroll into view are already fresh. Mutating an existing reactive item re-renders only that mounted row, so the work per tick stays proportional to what is visible - it never grows with the dataset, which is the point of pairing virtualization with a live feed.

import type { ScrollDetails } from '@pdanpdan/virtual-scroll';

// The visible window, refreshed from every @scroll event.
let range: { start: number; end: number } | undefined;
function onScroll(d: ScrollDetails) {
  range = d.range;
}

// One feed tick updates only the rows that are (nearly) on screen, so the work
// is O(viewport) and never grows with the dataset. Mutating an existing
// reactive item re-renders only its mounted row.
function applyTick() {
  if (!range) return;
  const from = Math.max(0, range.start - 2);
  const to = Math.min(rows.length - 1, range.end + 2);
  for (let i = from; i <= to; i++) {
    rows[i].price = Math.max(1, rows[i].price * (1 + (Math.random() - 0.5) * 0.02));
  }
}

setInterval(applyTick, 1000);
waiting for the first tick…
AAPL 000
Alpha Works
$138.61
▲ 0.00
MSFT 000
Blue Ridge
$205.07
▲ 0.00
NVDA 000
Cascade Tech
$46.18
▲ 0.00
GOOGL 000
Delta Systems
$91.87
▲ 0.00
AMZN 000
Evergreen Co
$262.52
▲ 0.00
META 000
Falcon Labs
$138.00
▲ 0.00
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • DOM Items #
  • Total Size (px)
    0h
  • Viewport Size (px)
    0h
  • Scroll Offset (px)
    0y