tutorial
Build a real-time operations console in Svelte
Last updated 8 September 2026
A Svelte operations screen shows several related things at once: orders coming in, shipments moving, incidents opening and closing, a throughput line ticking along. They are all the same live source. This tutorial takes one feed and drives the whole screen from it: three grids and a chart, each on its own element that only ever sees its own slice, with no backend to run.
You will build it with a stand-in socket so it runs anywhere, then change a single line to point it at a real server. The finished console is one click away if you want to see the destination first. If you would rather follow this in plain JavaScript, the vanilla version builds the same screen without a framework.
Open the finished console in the sandbox
The problem: one feed, many views
The obvious ways to build this screen both hurt. Open a socket per grid and you are running several connections that carry the same data, reconnecting several times, and reconciling them against each other. Put everything in one giant grid and you lose the thing that made the screen readable: orders, shipments and incidents are different shapes with different columns, and a reader wants them side by side, not interleaved.
The better shape is a router in front and plain elements behind. One connection arrives, a router splits each record to the view it belongs in, and every grid stays simple: it is handed rows and knows nothing about the feed or its siblings. That is what the Data Router does, and it is the whole idea this tutorial builds on. You can see the end result on the Data Router demo and read the API it uses in the rows and sources reference.
Set up the component
The Svelte adapter is a use: action, not a component, so its factory
takes only createGrid: Svelte compiles away, and there is no framework
runtime to hand in. The action carries no second copy of the grid, so it cannot
disagree with the version you installed. Import the charts module and the
data-router module beside it, and pull in onMount for the one place
that wires everything together. On localhost the grid is free to use; a deployed
site is licensed per domain, which the sandbox already carries for you.
<script>
import { createGrid, createHeadlessGrid } from 'https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.52.0/lattice-grid.esm.min.js';
import createLatticeAction from 'https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.52.0/modules/svelte.esm.min.js';
import { createChart } from 'https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.52.0/modules/charts.esm.min.js';
import { createDataRouter } from 'https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.52.0/modules/data-router.esm.min.js';
import { onMount } from 'svelte';
// The adapter is an action, so nothing but createGrid is passed in: Svelte
// compiles away and carries no runtime to hand over, and the action holds no
// second copy of the grid.
const lattice = createLatticeAction({ createGrid });
Build the feed
A live feed sends an opening snapshot, then a stream of changes. The helper below
does exactly that from a generator, on a timer, and it presents the same surface
as the browser's WebSocket: an onmessage handler, a
readyState, send and close. Because it
matches the real thing, the code you write against it is the code you ship. This
part is the same whether you build the screen in Svelte or in plain JavaScript.
// A serverless stand-in for a live feed. It opens, sends a snapshot, then
// streams deltas on a timer, all from a generator you hand it. To go live,
// swap the one line that constructs it for: new WebSocket(url)
function rng(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
class MockWebSocket {
constructor({ feed, rate = 1000, jitter = 0, seed = 1, snapshotDelay = 60 }) {
this.readyState = 0;
this.url = 'mock://feed';
this.onopen = this.onmessage = this.onclose = this.onerror = null;
this._feed = feed; this._rate = rate; this._jitter = jitter; this._rand = rng(seed);
this._timer = null; this._paused = false;
setTimeout(() => this._open(), snapshotDelay);
}
send() {} // a real socket sends upstream; here, ignored
pause() { this._paused = true; if (this._timer) { clearTimeout(this._timer); this._timer = null; } }
resume() { if (!this._paused) return; this._paused = false; this._schedule(); }
close() { if (this.readyState === 3) return; this.readyState = 3; if (this._timer) clearTimeout(this._timer); this._timer = null; this.onclose && this.onclose({ type: 'close' }); }
_open() { this.readyState = 1; this.onopen && this.onopen({ type: 'open' }); this._pump(); this._schedule(); }
_schedule() {
if (this._timer || this._paused || this.readyState !== 1) return;
var gap = Math.max(0, this._rate + (this._rand() * 2 - 1) * this._jitter);
this._timer = setTimeout(() => { this._timer = null; this._pump(); this._schedule(); }, gap);
}
_pump() {
if (this.readyState !== 1) return;
var next = this._feed.next();
if (next.done) return this.close();
this.onmessage && this.onmessage({ type: 'message', data: JSON.stringify(next.value) });
}
}
The generator is where your domain lives. This one emits a mixed operations
stream: orders, shipments and incidents, plus a small throughput rollup for the
chart. Every record carries a type, which is what the router will
split on, and an id, which is its key. The snapshot fills the screen;
each delta upserts a few records and occasionally resolves an incident.
// One mixed stream: orders, shipments, incidents and a throughput rollup.
// Every record carries a 'type', which is the field the router partitions on,
// and an 'id', its row key. The snapshot fills the screen; each delta upserts a
// few records and now and then resolves an incident.
function* opsFeed({ seed = 7, orders = 40, shipments = 24, incidents = 12, batch = 4 } = {}) {
var rand = rng(seed);
var REGIONS = ['EMEA', 'AMER', 'APAC'];
var CUSTOMERS = ['Northwind', 'Contoso', 'Fabrikam', 'Initech', 'Umbra', 'Globex'];
var CARRIERS = ['DHL', 'FedEx', 'Maersk', 'UPS'];
var SERVICES = ['Checkout', 'Search', 'Payments', 'Inventory', 'Auth'];
var SEVERITIES = ['low', 'medium', 'high', 'critical'];
var pick = function (list) { return list[Math.floor(rand() * list.length)]; };
var seq = 0, tick = 0;
var openInc = new Set();
var order = () => ({ id: 'ORD-' + (1000 + seq++), type: 'order', region: pick(REGIONS), customer: pick(CUSTOMERS), value: 200 + Math.round(rand() * 9800), status: 'open' });
var shipment = () => ({ id: 'SHP-' + (1000 + seq++), type: 'shipment', region: pick(REGIONS), carrier: pick(CARRIERS), units: 1 + Math.round(rand() * 400), status: 'in transit' });
var incident = () => { var id = 'INC-' + (1000 + seq++); openInc.add(id); return { id, type: 'incident', region: pick(REGIONS), service: pick(SERVICES), severity: pick(SEVERITIES), status: 'open' }; };
var metric = (events) => ({ id: 'M-' + tick, type: 'metric', t: tick, events });
var rows = [];
for (var i = 0; i < orders; i++) rows.push(order());
for (var i = 0; i < shipments; i++) rows.push(shipment());
for (var i = 0; i < incidents; i++) rows.push(incident());
for (tick = 0; tick < 12; tick++) rows.push(metric(1 + Math.floor(rand() * batch * 2)));
yield { kind: 'snapshot', rows };
for (;;) {
var changes = [];
var n = 1 + Math.floor(rand() * batch);
for (var i = 0; i < n; i++) {
var roll = rand();
changes.push({ op: 'upsert', row: roll < 0.45 ? order() : roll < 0.75 ? shipment() : incident() });
}
if (rand() < 0.5 && openInc.size) {
var id = [...openInc][Math.floor(rand() * openInc.size)];
openInc.delete(id);
changes.push({ op: 'upsert', row: { id, type: 'incident', status: 'resolved', region: pick(REGIONS), service: pick(SERVICES), severity: pick(SEVERITIES) } });
}
changes.push({ op: 'upsert', row: metric(changes.length) });
tick++;
yield { kind: 'delta', changes };
}
}
Define the columns
Declare a column set per grid, and keep a handle for each grid that its
onGrid callback will fill in. None of the grids carries a
rows config, because the router owns the rows: it loads the snapshot
and applies every delta, so the action and the router never contend for them.
const money = { style: 'currency', currency: 'USD', decimals: 0 };
const orderColumns = [
{ field: 'id', title: 'Order', layout: { width: 110 } },
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'customer', title: 'Customer' },
{ field: 'value', title: 'Value', type: 'number', format: money, total: 'sum' },
];
const shipmentColumns = [
{ field: 'id', title: 'Shipment', layout: { width: 110 } },
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'carrier', title: 'Carrier' },
{ field: 'units', title: 'Units', type: 'number', total: 'sum' },
{ field: 'status', title: 'Status' },
];
const incidentColumns = [
{ field: 'id', title: 'Incident', layout: { width: 110 } },
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'service', title: 'Service' },
{ field: 'severity', title: 'Severity' },
{ field: 'status', title: 'Status' },
];
// onGrid hands over each live grid the moment it is created; keep each one so
// the router can attach it once every grid exists.
let orders, shipments, incidents;
let chartEl;
Wire it together
One onMount wires it all together. Svelte runs each element's action
as the element mounts, so by the time onMount runs the three grids
exist and their handles are set. It creates the router keyed on type,
attaches each grid to the value it wants, links orders to incidents so selecting
orders narrows the incidents grid to the same regions, points the chart at a
headless grid, and opens the socket. The snapshot arrives as
router.load and every delta as router.apply, which
computes a keyed difference per grid so an update lands on exactly the row it
changes, with scroll and selection preserved. The function returned from
onMount closes the feed and tears the router down when the component
is destroyed; each grid on an element is destroyed by its own action.
onMount(() => {
// A headless grid holds the rollup with no table of its own; the chart reads it.
const metrics = createHeadlessGrid({
rowKey: 't',
columns: [{ field: 't', type: 'number' }, { field: 'events', type: 'number' }],
});
// One router, keyed on 'type'. Each grid is attached to the value it wants and
// only ever sees its own slice; the router fans the one feed out to all of them.
const router = createDataRouter({ key: 'type', rowKey: 'id' });
router.attach(orders, 'order');
router.attach(shipments, 'shipment');
router.attach(incidents, 'incident');
router.attach(metrics, 'metric', { rowKey: 't' });
// Cross-grid selection: pick orders and the incidents grid narrows to their
// regions. Neither grid holds a reference to the other.
router.link(orders, incidents, { from: 'region', to: 'region' });
// The chart follows the headless grid, so it moves as the feed does.
const chart = createChart({
grid: metrics, container: chartEl,
type: 'line', x: 't', y: 'events', title: 'Throughput per tick',
});
// One socket, one handler. The snapshot hydrates every grid at once; each
// delta lands in place through the same keyed path, so scroll and selection hold.
const socket = new MockWebSocket({ feed: opsFeed({ seed: 7 }), rate: 900, jitter: 300 });
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.kind === 'snapshot') router.load(message.rows);
else router.apply(message.changes);
};
// Destroy tears the feed and the router down with the component. Each grid on
// an element is destroyed by its own action, so the teardown leaves them be.
return () => { socket.close(); chart.destroy(); router.destroy(); metrics.destroy(); };
});
</script>
Mount it
Place the panels in the markup. Each sized element takes the action with its own column set, and the chart strip binds an element the wiring drew into. Putting these elements on the page is what mounts the grids, and one round trip hydrates every grid and the chart at once, with the stream keeping them all moving from there.
<svelte:head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.52.0/lattice-grid.min.css">
</svelte:head>
<div class="ops">
<div class="panel"><h2>Orders</h2>
<div class="grid" use:lattice={{ rowKey: 'id', theme: 'light', selection: 'multiple', columns: orderColumns, onGrid: (g) => (orders = g) }}></div></div>
<div class="panel"><h2>Shipments</h2>
<div class="grid" use:lattice={{ rowKey: 'id', theme: 'light', columns: shipmentColumns, onGrid: (g) => (shipments = g) }}></div></div>
<div class="panel"><h2>Incidents</h2>
<div class="grid" use:lattice={{ rowKey: 'id', theme: 'light', columns: incidentColumns, onGrid: (g) => (incidents = g) }}></div></div>
</div>
<div id="chart" bind:this={chartEl}></div>
<style>
:global(body) { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #f6f7f9; color: #1b2430; }
.ops { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.panel { background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; overflow: hidden; min-width: 0; }
.panel h2 { font-size: 13px; margin: 0; padding: 8px 12px; border-bottom: 1px solid #eef0f3; color: #3a4250; font-weight: 600; }
.grid { height: 300px; }
#chart { height: 200px; margin-top: 12px; background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; }
@media (max-width: 820px) { .ops { grid-template-columns: 1fr; } }
</style>
That is the whole console: one feed, three grids, a chart, and a link between two of the grids. Run it in the sandbox and edit any part of it live.
Go to production
When there is a real server to talk to, the change is one line inside
onMount. Everything that parses messages and routes them stays exactly
as written, because the stand-in was built to the real socket's surface from the
start.
// The whole change from mock to live is this one line, inside onMount:
const socket = new WebSocket('wss://ops.example.com/stream');
// everything that parses messages and routes them stays exactly as written.
Two notes for the live version. A deployed page needs a licence for its domain, which is a single call the sandbox already makes for you. And where a connection can drop, a reconnect that asks the server for a fresh snapshot restores the screen from the same handler you already have: the snapshot path rebuilds every grid, and the deltas carry on.
What you built
One round trip hydrated a whole screen, and one stream keeps it moving. Each grid is an independent element that knows nothing of the others, the chart rides the same feed, and the connection count stayed at one. Swapping the mock for a real socket is the only change between this page and production.
Next, see the Data Router demo for the same idea as a compact example, read the Svelte adapter guide for the action and its parameters in detail, or browse the demo catalogue for the grid features each panel here is using.