WebSocket live updates

Insert / update / delete deltas merged by id, cell-flash on update, pause / resume, throughput slider.

A live, editable Svelte 5 data grid example from the SvGrid gallery (Server-Side Data). See the SvGrid documentation for the full API.

About this example

Server-push updates merged into the Svelte 5 data grid by id. A long-lived connection streams row-level change events, insert with a row, update with an id and a patch, and delete with an id, and the client merges them into the local dataset so only affected rows re-render. getRowId keeps identity stable, updated cells flash yellow and fade, and controls pause and resume the stream and set throughput from 1 to 30 events per second; replace mockSocket with a WebSocket or EventSource and the merge stays the same.

Real-world server-push pattern: a long-lived connection streams row-level CHANGE events; the client merges them into the local dataset by id. The grid never reloads the full list - only the affected rows re-render.

Event shapes: { type: 'insert', row } - new order arrived { type: 'update', id, patch } - partial row update (status, amount, eta) { type: 'delete', id } - order cancelled / archived

Production wiring: replace mockSocket with a real WebSocket(...) or EventSource(...). The merge function stays unchanged.

Features:

  • Cell-flash animation on update (yellow → fade)
  • Pause / resume the stream
  • Throughput slider (1 - 30 events/sec)
  • Per-event log with type chip

Imports, features and API used

Imports: svelte, @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: id (Order), customer (Customer), sku (SKU), qty (Qty), amount (Amount), status (Status), region (Region), eta (ETA), placedAt (Placed)

Frequently asked questions

How are events merged without reloading the list?

Rows are kept in a map by id. insert adds a row, update applies the patch to the existing object and delete removes it; the grid receives the new array and, thanks to getRowId, only re-renders rows whose object changed.

How is the cell flash done?

The merge records a timestamp per updated cell keyed by row id and field; cellClass adds a flash class while the entry is younger than 1100 ms, and a polling tick re-evaluates so the class drops off when the time is up.

What changes for a real WebSocket?

Only the source. Wire socket.onmessage to parse the event and call the same merge function; the pause buffer, throughput and log work unchanged.

Related documentation

Related articles

Source code (116-websocket-live-updates.svelte)

<script lang="ts">
  /**
   * 116. WebSocket live updates (insert / update / delete deltas)
   * -------------------------------------------------------------
   * Real-world server-push pattern: a long-lived connection streams
   * row-level CHANGE events; the client merges them into the local
   * dataset by id. The grid never reloads the full list - only the
   * affected rows re-render.
   *
   * Event shapes:
   *   { type: 'insert', row }     - new order arrived
   *   { type: 'update', id, patch } - partial row update (status, amount, eta)
   *   { type: 'delete', id }      - order cancelled / archived
   *
   * Production wiring: replace `mockSocket` with a real `WebSocket(...)`
   * or `EventSource(...)`. The merge function stays unchanged.
   *
   * Features:
   *   - Cell-flash animation on update (yellow → fade)
   *   - Pause / resume the stream
   *   - Throughput slider (1 - 30 events/sec)
   *   - Per-event log with type chip
   */
  import { untrack } from 'svelte'
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    renderSnippet,
    type GridColumns,
  } from '@svgrid/grid'

  type Status = 'placed' | 'paid' | 'picking' | 'shipped' | 'delivered' | 'cancelled'
  type Order = {
    id: string
    customer: string
    sku: string
    qty: number
    amount: number
    status: Status
    region: 'NA' | 'EU' | 'APAC'
    placedAt: string
    eta: string
  }

  // ---- Seed dataset ----------------------------------------------------
  let prng = 0x10101
  function rand() { prng = (prng * 1664525 + 1013904223) >>> 0; return prng / 0xFFFFFFFF }
  function pick<T>(a: readonly T[]): T { return a[Math.floor(rand() * a.length)]! }
  function int(min: number, max: number) { return Math.floor(min + rand() * (max - min + 1)) }
  function nowIso() { return new Date().toISOString().slice(0, 19).replace('T', ' ') }

  const CUSTOMERS = ['Ava T.', 'Liam P.', 'Noah S.', 'Emma G.', 'Olivia C.',
                     'Mason R.', 'Sophia B.', 'Lucas P.', 'Mia J.', 'Ethan W.']
  const SKUS = ['MUG-NAVY-12', 'TOTE-RED-L', 'BOTTLE-32-OZ', 'NB-A5-HARD',
                'HAT-BB-NAVY', 'PEN-CHISEL-2', 'STICKER-10']
  const REGIONS: Order['region'][] = ['NA', 'EU', 'APAC']
  const STATUSES: Status[] = ['placed', 'paid', 'picking', 'shipped', 'delivered', 'cancelled']

  let nextId = 30_001
  function newOrder(): Order {
    const id = `ORD-${nextId++}`
    const qty = int(1, 8)
    return {
      id,
      customer: pick(CUSTOMERS),
      sku: pick(SKUS),
      qty,
      amount: qty * int(8, 60),
      status: 'placed',
      region: pick(REGIONS),
      placedAt: nowIso(),
      eta: '-',
    }
  }
  let rows = $state<Order[]>(Array.from({ length: 30 }, () => {
    const o = newOrder()
    // Distribute initial statuses so the demo doesn't start with 30 "placed".
    o.status = pick(STATUSES.slice(0, 5)) // exclude cancelled
    if (o.status === 'shipped' || o.status === 'delivered') {
      o.eta = new Date(Date.now() + int(0, 5) * 86_400_000).toISOString().slice(0, 10)
    }
    return o
  }))

  // ---- Cell-flash tracker --------------------------------------------
  // Each updated cell flashes for 1100ms. Track the timestamp keyed by
  // `${rowId}::${field}`; the cellClass callback adds the .flash class
  // when the entry is recent. A polling tick re-evaluates so flashes
  // fade naturally.
  let flashes = $state<Record<string, number>>({})
  let flashTick = $state(0)
  $effect(() => {
    const id = setInterval(() => { flashTick++ }, 200)
    return () => clearInterval(id)
  })
  function markFlash(rowId: string, field: string) {
    flashes = { ...flashes, [`${rowId}::${field}`]: performance.now() }
  }
  function isFlashed(rowId: string, field: string): boolean {
    const t = flashes[`${rowId}::${field}`]
    return t != null && performance.now() - t < 1100
  }

  // ---- Merge engine - the heart of every WS client --------------------
  type WsEvent =
    | { type: 'insert'; row: Order }
    | { type: 'update'; id: string; patch: Partial<Order> }
    | { type: 'delete'; id: string }

  const ROW_HEIGHT = 28
  let gridHost = $state<HTMLElement | null>(null)

  /** Compensate scrollTop when prepending so the user's viewport stays
   *  visually anchored. Without this, every insert shifts all visible
   *  rows down by one rowHeight and the user perceives a "jump to top"
   *  (and effectively can't scroll because new events keep coming). */
  function preserveScrollOnPrepend(insertedCount: number) {
    const sc = gridHost?.querySelector<HTMLElement>('.sv-grid-container')
    if (!sc) return
    // When the user is at the very top (scrollTop ≈ 0), let the new
    // row appear in place. When they've scrolled down at all, bump
    // scrollTop so the previously-visible rows stay put.
    if (sc.scrollTop > 1) {
      sc.scrollTop += ROW_HEIGHT * insertedCount
    }
  }

  function applyEvent(e: WsEvent) {
    if (e.type === 'insert') {
      // Prepend so the newest is visible at the top.
      rows = [e.row, ...rows]
      // Cap to 200 to keep the demo bounded
      if (rows.length > 200) rows = rows.slice(0, 200)
      for (const k of Object.keys(e.row)) markFlash(e.row.id, k)
      // After the new row has painted, compensate scroll.
      requestAnimationFrame(() => preserveScrollOnPrepend(1))
    } else if (e.type === 'update') {
      const idx = rows.findIndex((r) => r.id === e.id)
      if (idx < 0) return
      const before = rows[idx]!
      rows = rows.map((r, i) => i === idx ? { ...r, ...e.patch } : r)
      for (const k of Object.keys(e.patch)) {
        if ((before as Record<string, unknown>)[k] !== (e.patch as Record<string, unknown>)[k]) {
          markFlash(e.id, k)
        }
      }
    } else {
      rows = rows.filter((r) => r.id !== e.id)
    }
    pushLog(e)
  }

  // ---- Mock socket: emits random events at a configurable rate -------
  let paused = $state(false)
  let rate = $state(8)            // events per second
  let socketActive = $state(true)
  let totalEvents = $state(0)
  let counters = $state({ insert: 0, update: 0, delete: 0 })

  // Drive the mock-socket clock. The effect only depends on `socketActive`
  // and `rate`; `paused` is read inside `untrack` so toggling it doesn't
  // re-run the effect (and `emitOne()` mutating `rows` doesn't either,
  // which would otherwise create an infinite reactivity loop).
  $effect(() => {
    if (!socketActive) return
    const intervalMs = Math.max(33, 1000 / rate)
    let timer: ReturnType<typeof setTimeout> | null = null
    function tick() {
      untrack(() => { if (!paused) emitOne() })
      timer = setTimeout(tick, intervalMs)
    }
    timer = setTimeout(tick, intervalMs)
    return () => { if (timer) clearTimeout(timer) }
  })

  function emitOne() {
    const dice = Math.random()
    let event: WsEvent
    if (dice < 0.18 || rows.length < 5) {
      event = { type: 'insert', row: newOrder() }
    } else if (dice < 0.92 && rows.length > 0) {
      const target = rows[Math.floor(Math.random() * Math.min(40, rows.length))]!
      const patch: Partial<Order> = {}
      const stagesAhead: Status[] = (() => {
        const order: Status[] = ['placed', 'paid', 'picking', 'shipped', 'delivered']
        const idx = order.indexOf(target.status)
        if (idx === -1 || idx === order.length - 1) return []
        return order.slice(idx + 1, idx + 2)
      })()
      // ~70%: status advances; ~30%: just nudge amount/eta
      if (stagesAhead.length && Math.random() < 0.7) {
        patch.status = stagesAhead[0]
        if (patch.status === 'shipped' || patch.status === 'delivered') {
          patch.eta = new Date(Date.now() + int(0, 4) * 86_400_000).toISOString().slice(0, 10)
        }
      } else {
        patch.amount = target.amount + int(-15, 15)
      }
      event = { type: 'update', id: target.id, patch }
    } else if (rows.length > 0) {
      const target = rows[Math.floor(Math.random() * rows.length)]!
      event = { type: 'delete', id: target.id }
    } else {
      return
    }
    applyEvent(event)
  }

  // ---- Event log ------------------------------------------------------
  type LogEntry = { at: string; type: WsEvent['type']; id: string; desc: string }
  let log = $state<LogEntry[]>([])
  function pushLog(e: WsEvent) {
    totalEvents++
    counters = { ...counters, [e.type]: counters[e.type] + 1 }
    const t = new Date()
    const at = `${t.getHours().toString().padStart(2, '0')}:${t.getMinutes().toString().padStart(2, '0')}:${t.getSeconds().toString().padStart(2, '0')}`
    let desc = ''
    if (e.type === 'insert') desc = `${e.row.customer} → ${e.row.sku} ($${e.row.amount})`
    else if (e.type === 'update') desc = Object.entries(e.patch).map(([k, v]) => `${k}=${v}`).join(' · ')
    else desc = 'cancelled'
    log = [{ at, type: e.type, id: e.type === 'insert' ? e.row.id : e.id, desc }, ...log].slice(0, 12)
  }

  function reset() {
    rows = []
    log = []
    counters = { insert: 0, update: 0, delete: 0 }
    totalEvents = 0
    flashes = {}
  }

  // ---- Columns --------------------------------------------------------
  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  function flashClass(field: string) {
    return (ctx: { row: { original: Order } }) => {
      const _ = flashTick // depend on the tick so the class re-evaluates
      return isFlashed(ctx.row.original.id, field) ? `flash flash-${field}` : ''
    }
  }

  const columns: GridColumns<Order> = [
    { field: 'id',       header: 'Order',    width: 110, editable: false,
      cellClass: flashClass('id') },
    { field: 'customer', header: 'Customer', width: 130, editable: false,
      cellClass: flashClass('customer') },
    { field: 'sku',      header: 'SKU',      width: 140, editable: false,
      cellClass: flashClass('sku') },
    { field: 'qty',      header: 'Qty',      width:  80, editable: false, align: 'right',
      cellClass: flashClass('qty') },
    { field: 'amount',   header: 'Amount',   width: 110, editable: false, align: 'right',
      format: { type: 'number', options: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } },
      cellClass: flashClass('amount') },
    { field: 'status',   header: 'Status',   width: 120, editable: false,
      cellClass: (ctx) => `status status-${ctx.getValue()} ${isFlashed(ctx.row.original.id, 'status') ? 'flash flash-status' : ''}` },
    { field: 'region',   header: 'Region',   width:  80, editable: false,
      cellClass: flashClass('region') },
    { field: 'eta',      header: 'ETA',      width: 100, editable: false,
      cellClass: flashClass('eta') },
    { field: 'placedAt', header: 'Placed',   width: 160, editable: false },
  ]
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <!-- Connection toolbar ---------------------------------------- -->
  <div class="ws-bar shrink-0">
    <div class="ws-status">
      <span class={`dot ${socketActive && !paused ? 'live' : 'idle'}`}></span>
      <span class="status-label">{socketActive ? (paused ? 'PAUSED' : 'LIVE') : 'DISCONNECTED'}</span>
      <span class="status-meta">wss://mock-orders.example/v1</span>
    </div>

    <div class="controls">
      <label class="ctrl">
        <span>Throughput</span>
        <input type="range" min="1" max="30" bind:value={rate} />
        <span class="rate">{rate}/s</span>
      </label>
      {#if paused}
        <button class="btn primary" onclick={() => (paused = false)}>▶ Resume</button>
      {:else}
        <button class="btn" onclick={() => (paused = true)}>⏸ Pause</button>
      {/if}
      {#if socketActive}
        <button class="btn alt" onclick={() => { socketActive = false; paused = true }}>⏹ Disconnect</button>
      {:else}
        <button class="btn primary" onclick={() => { socketActive = true; paused = false }}>↻ Reconnect</button>
      {/if}
      <button class="btn alt" onclick={reset}>Reset</button>
    </div>
  </div>

  <!-- Stats strip ----------------------------------------------- -->
  <div class="stats shrink-0">
    <div class="stat"><div class="stat-label">Rows in view</div><div class="stat-value">{rows.length}</div></div>
    <div class="stat"><div class="stat-label">Total events</div><div class="stat-value">{totalEvents}</div></div>
    <div class="stat"><div class="stat-label">Inserts</div><div class="stat-value insert">{counters.insert}</div></div>
    <div class="stat"><div class="stat-label">Updates</div><div class="stat-value update">{counters.update}</div></div>
    <div class="stat"><div class="stat-label">Deletes</div><div class="stat-value delete">{counters.delete}</div></div>
  </div>

  <div class="flex-1 min-h-0" bind:this={gridHost}>
    <SvGrid responsive={true}
      columnResize
      data={rows}
      columns={columns}
      features={features}
      filterMode="menu"
      selectionMode="cell"
      enableInlineEditing={false}
      enableCellSelection={true}
      rowHeight={28}
      containerHeight="100%"
      fitColumns={true}
      getRowId={(r) => r.id}
    />
  </div>

  <!-- Event stream tail ----------------------------------------- -->
  <div class="log shrink-0">
    <div class="log-head"><strong>Event stream</strong> <span class="log-meta">last 12 messages</span></div>
    {#if log.length === 0}
      <div class="log-empty">Waiting for the first event…</div>
    {:else}
      <ul>
        {#each log as e, i (e.at + e.id + i)}
          <li>
            <span class="log-time">{e.at}</span>
            <span class={`log-type log-type-${e.type}`}>{e.type}</span>
            <span class="log-id">{e.id}</span>
            <span class="log-desc">{e.desc}</span>
          </li>
        {/each}
      </ul>
    {/if}
  </div>
</section>

<style>
  .ws-bar {
    display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
    border: 1px solid var(--sg-border, #e2e8f0);
    background: color-mix(in srgb, var(--sg-accent, #6366f1) 4%, transparent);
    border-radius: 8px; padding: 10px 12px;
  }
  .ws-status { display: inline-flex; align-items: center; gap: 8px; }
  .dot {
    display: inline-block; width: 10px; height: 10px; border-radius: 50%;
  }
  .dot.live { background: #16a34a; box-shadow: 0 0 0 4px color-mix(in oklab, #16a34a 25%, transparent);
              animation: dot-pulse 1.4s ease-out infinite; }
  .dot.idle { background: var(--sg-muted, #94a3b8); }
  @keyframes dot-pulse {
    0%   { box-shadow: 0 0 0 0   color-mix(in oklab, #16a34a 35%, transparent); }
    70%  { box-shadow: 0 0 0 9px color-mix(in oklab, #16a34a 0%,  transparent); }
    100% { box-shadow: 0 0 0 0   color-mix(in oklab, #16a34a 0%,  transparent); }
  }
  .status-label {
    font-size: 12px; font-weight: 800; letter-spacing: 0.06em;
    color: var(--sg-fg, #0f172a);
  }
  .status-meta {
    font-family: ui-monospace, monospace;
    font-size: 11px; color: var(--sg-muted, #64748b);
  }
  .controls { margin-left: auto; display: flex; align-items: center; gap: 8px; }
  .ctrl {
    display: inline-flex; align-items: center; gap: 6px;
    font-size: 12px; color: var(--sg-fg, #0f172a);
  }
  .ctrl > span { font-size: 10px; font-weight: 700; text-transform: uppercase;
                 letter-spacing: 0.06em; color: var(--sg-muted, #64748b); }
  .ctrl input[type="range"] { width: 130px; accent-color: var(--sg-accent, #6366f1); }
  .rate { font-variant-numeric: tabular-nums; font-weight: 700; min-width: 38px; }

  .btn {
    background: var(--sg-bg, #fff);
    border: 1px solid var(--sg-border, #cbd5e1);
    color: var(--sg-fg, #0f172a);
    border-radius: 6px; padding: 5px 12px;
    font-size: 12px; font-weight: 700; cursor: pointer;
  }
  .btn:hover { background: color-mix(in oklab, var(--sg-accent, #6366f1) 6%, transparent); }
  .btn.primary { background: var(--sg-accent, #6366f1); color: var(--sg-on-accent, #fff); border-color: transparent; }
  .btn.alt { color: var(--sg-muted, #64748b); }

  .stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
  .stat {
    border: 1px solid var(--sg-border, #e2e8f0);
    background: var(--sg-bg, #fff);
    border-radius: 8px; padding: 6px 12px;
  }
  .stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
                color: var(--sg-muted, #64748b); font-weight: 700; }
  .stat-value { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; }
  .stat-value.insert { color: #16a34a; }
  .stat-value.update { color: #6366f1; }
  .stat-value.delete { color: #b91c1c; }

  /* Cell flash animation -------------------------------------- */
  :global(td.flash) {
    animation: cell-flash 1100ms ease-out;
  }
  @keyframes cell-flash {
    0%   { background: #fef9c3; }
    100% { background: transparent; }
  }
  /* Status pills */
  :global(td.status)             { font-weight: 700; }
  :global(td.status-placed)      { color: #6366f1; }
  :global(td.status-paid)        { color: #0ea5e9; }
  :global(td.status-picking)     { color: #d97706; }
  :global(td.status-shipped)     { color: #ca8a04; }
  :global(td.status-delivered)   { color: #16a34a; }
  :global(td.status-cancelled)   { color: #b91c1c; text-decoration: line-through;
                                   text-decoration-color: rgba(185,28,28,0.4); }

  .log {
    border: 1px solid var(--sg-border, #e2e8f0);
    background: var(--sg-bg, #fff);
    border-radius: 8px;
    max-height: 180px; display: flex; flex-direction: column;
  }
  .log-head {
    display: flex; align-items: center; justify-content: space-between;
    padding: 8px 12px;
    border-bottom: 1px solid var(--sg-border, #e2e8f0);
    background: color-mix(in oklab, var(--sg-accent, #6366f1) 4%, transparent);
    font-size: 11px; font-weight: 700; text-transform: uppercase;
    letter-spacing: 0.06em; color: var(--sg-muted, #64748b);
  }
  .log-meta { font-weight: 500; }
  .log-empty { padding: 14px; text-align: center; font-style: italic;
               color: var(--sg-muted, #94a3b8); font-size: 12px; }
  .log ul { list-style: none; margin: 0; padding: 4px 0; overflow-y: auto; }
  .log li {
    display: grid; grid-template-columns: 70px 80px 100px 1fr; gap: 8px;
    padding: 2px 12px; font-size: 11.5px; align-items: center;
  }
  .log li:first-child { background: color-mix(in oklab, var(--sg-accent, #6366f1) 5%, transparent); }
  .log-time { font-family: ui-monospace, monospace; color: var(--sg-muted, #64748b); }
  .log-id   { font-family: ui-monospace, monospace; color: var(--sg-fg, #0f172a); }
  .log-type {
    text-align: center; font-size: 9px; font-weight: 800;
    text-transform: uppercase; letter-spacing: 0.06em;
    padding: 1px 6px; border-radius: 4px;
  }
  .log-type-insert { background: #dcfce7; color: #166534; }
  .log-type-update { background: #ede9fe; color: #5b21b6; }
  .log-type-delete { background: #fee2e2; color: #991b1b; }
  .log-desc { color: var(--sg-muted, #64748b);
              overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>

View this example on GitHub

More Server-Side Data examples

  • Server-side data - Sort/filter/page round-tripped to a mock endpoint with debounce + cancel.
  • Server-side infinite scroll - 100k-event audit log behind a mock API. Sparse chunked load on scroll; sort + filter + search pushed to the server.
  • Server-Side Row Model (SSRM) - One datasource contract for server-backed data: implement a single async getRows({ startRow, endRow, sortModel, filterModel }) and createServerDataSource owns the sort/filter/page lifecycle and races stale responses away. Here a 100,000-row in-memory server behind 250ms latency; the grid holds only the current 50-row page. The row model ships in @svgrid/enterprise.
  • Server grouping (first-class) - First-class server-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerGroupModel owns the group tree - lazy expand per level, aggregation, per-node caching, race-safety - handing back a flat displayRows list. Here a 63,000-row in-memory server behind 200ms latency; the grid holds only the groups you expand. The row model ships in @svgrid/enterprise.
  • GraphQL adapter - Server-side sort / filter / page wired to a mock GraphQL resolver. Side panel shows the live query doc so you can compare what the grid sent to the network tab.