This app runs the workload of a customer support desk run by agents: hundreds of customer accounts, each with a team of agents and people watching live queries of its tickets.
The server model
A server that keeps live queries fresh has two choices. It can re-run every query on a timer. Or it can keep each result and apply every write to it as a change. This board holds thousands of an issue tracker's live queries on one rindled, and prices both.
const issueList = defineQuery("issue-list", projectArg,
({ project }) =>
q.issue
.where(and(
fieldCondition("project", eq(project)),
open(),
))
.orderBy("updated", "desc")
.limit(12)
.sub("owner", rels.issueAssignee, person)
.sub("latest", rels.issueComments,
newestComments(2)),
);
01 / The workload
Every card on the board is one live query that someone in a tracker has open: a project's open issues, a kanban board, a backlog, one issue's thread, a status dashboard, a saved search. There are 320 projects, 51,823 issues and 11 query shapes.
The connected users and queries / user sliders hold up to 20,000 of them at once, all on one rindled. Every viewer of this page shares that roster, so a second viewer adds subscriptions but no new pipelines.
// Polling: re-run every query on a timer. setInterval(() => { for (const q of roster) { // read the whole query again const rows = db.all(q.sql); if (changed(q, rows)) send(q, rows); } }, interval); // IVM: read each query once, // then apply each write to it. for (const q of roster) q.materialize(); // each commit → a delta for each query // that it changes
02 / Two ways to keep it fresh
A poller re-runs every query on every interval, whether anything changed or not. Its cost is the rows each query reads, times the number of queries, times the polls per second. The write rate does not change it.
An incremental engine runs each query once. After that, it pushes each committed row through only the queries that row can change. Its cost follows the write rate. The size of a query matters much less, because a write moves a few rows of a result, not all of it.
03 / Where each wins
The chart puts the two costs on one map. Two of its axes are controls on the board: writes per second is users × writes / user / min, and the polling interval is the equiv qps stat, which you click for 100 ms, 1 s or 5 s. The third axis, how many rows each poll reads, is the bands.
Point at the map to see how large a query must be before IVM costs less there.
The range measured on Fly. Everything outside it is extrapolated.
AssumesMaintaining a query costs the same per write however large it is. That is close for a window a write moves one row of. It is not true of every shape.
Not counted04 / Read the board
Equiv QPS @1s is the query rate a database would have to serve to keep every card on the board fresh by polling: live queries divided by the interval. Its hover says how many of those polls would find anything, from what the board has seen change.
These queries are light: indexed lookups under a limit, about a hundred rows
read per poll. The board sits on the chart's 1× line, where polling does best.
05 / Keep it honest
On the map, the poller is warm, memory-mapped SQLite with prepared statements, good indexes and hand-written SQL, in the same process as its data: no network, no connection, and one poll per query however many clients watch it. It is charged for committing the same writes rindled commits. Neither side is charged for sending results to viewers.
Polling is not run live, because a second SQLite would share rindled's CPU and spoil its number.
The trade
CPU is not the only difference. A maintained view changes when the write lands, not at the next poll, and every view moves together.