The usual real-time stack solves a real problem
A real-time feature looks simple in the demo and complicated in production. A score changes, and every watching client should see the new number within a frame or two, whether it connected an hour ago or half a second ago. The update itself is rarely the hard part. The hard part is everything the update has to travel through to arrive, and everything that has to agree on it once it does.
Most teams building this already know the standard shape, and for high-volume messaging it is often exactly the right one. The narrower question is worth asking anyway: when the thing you are broadcasting is a state change you also want to keep, does delivering it really require stitching together several services that each hold a different piece of the truth?
A conventional live-update path has four moving parts, and each one is there for a reason. A broker (Mosquitto, EMQX, or a managed equivalent) handles publish and subscribe fan-out. A bridge consumer reads off the broker and writes messages into a database, because the broker itself is not durable storage. A cache or a current-state store lets a client that just connected be hydrated without replaying the entire history. And a WebSocket gateway sits in front, because browsers do not speak native MQTT over TCP, so something has to translate.
That is a proven architecture, particularly when message volume is high and producers and consumers need to scale independently of the data.
The trade-off is that the truth is now spread across four systems. The broker knows the latest message, the database knows the durable log, the cache knows the current state, and the gateway knows who is connected. Every one of them is a service to deploy, monitor, and keep in agreement, and the seams between them are where late joiners get stale scores and where "it worked in the demo" quietly stops being true.
Those costs may be justified by the workload. The point is that they should be justified by the workload, rather than assumed to be the only way to move a number to a screen.
What if the message and the record were the same write, and subscribing to the record were the subscription?
Testing another shape with a live scoreboard
To make that concrete, we built an MQTT Live Scoring demo on Harper. The workload is deliberately familiar: score events arrive, a scoreboard updates, and a running feed shows the ball-by-ball history. It looks like any app where a change has to fan out to everyone watching, whether that is a match, a delivery tracker, an auction, or a dashboard.
The design turns on a single decision, made before any code: a scoreboard is two different problems wearing one screen. The scoreboard total is convergent state, where only the latest value matters. The feed is an event stream, where every entry matters and nothing collapses. The demo models them as two tables that admit exactly that difference.
# Match: convergent state. Only the latest value matters, so the record is the topic.
type Match @table(database: "mqtt_live_scoring") @export {
id: ID @primaryKey
homeScore: Int
awayScore: Int
minute: Int
updatedAt: Float
}
# ScoreEvent: the durable log. Every message matters, so rows are kept individually.
type ScoreEvent @table(database: "mqtt_live_scoring") @export {
id: ID @primaryKey
matchId: String @indexed
team: String
kind: String
points: Int
createdAt: Float @indexed
}
Separating them is not bookkeeping. It is what lets the next two mechanisms be as small as they are.
The record is the topic
In Harper, writing the Match record is the fan-out. There is no separate publish step after the write, because subscribers of the Match/{matchId} topic are notified by the write itself. A client that subscribes is handed the current record immediately as the retained message, then every update after it.
That last sentence is the entire late-joiner story. In the four-service stack, hydrating a client that just connected is what the cache tier is for: read current state from somewhere fast so the newcomer is not replaying a log. Here, the newcomer subscribes to the match topic and the first thing it receives is the current record. The cache tier is not optimized. It is absent, because the job it was doing is a property of subscribing to a record.
Harper did not make the broadcast faster. On a subscribe, the work the cache tier used to do is no longer in the path.
One path, two transports
The write side is a single custom resource, ScoreIngest, mounted at the same path the MQTT topic uses. An MQTT publish to live-scoring/{matchId}/events and an HTTP POST /live-scoring/{matchId}/events land in the same class, in the same process as the database, because in Harper the topic namespace and the URL space are one namespace.
export class ScoreIngest extends Resource {
static path = '/live-scoring/:matchId/events';
// HTTP entry point.
async post(target, data) {
return ingest(matchIdFor(target, data), data);
}
// MQTT entry point. Same ingest, same process, same commit.
async publish(target, data) {
return ingest(matchIdFor(target, data), data);
}
}
Inside ingest, the durable ScoreEvent row and the updated Match aggregate are written one after the other, in the database process. There is no bridge consumer between a broker and a store, because there is no gap between them to bridge. The event is recorded and the scoreboard is updated as one piece of work, and the same write that updates the scoreboard is the one that notifies every subscriber.
Every number on the screen is a real measurement
The demo carries a telemetry strip, and it is where the architecture stops being a claim and starts being observable. Each stage of handling an event is timed server-side with process.hrtime: validate, persist the event, update the aggregate, total. Those timings are not returned only to the client that published. They are stored on the ScoreEvent row, so a browser that is merely watching renders the same numbers as the one that clicked.
The frontend takes that honesty a step further than it needs to. It does not tween the values, because a count-up animation would put numbers on screen the backend never reported. And it shows a dash rather than 0.00ms for a stage that rounds to zero, because validation lands in the single-microsecond range, and 0.00ms would claim the work took no time rather than admitting it was too small to show at two decimals.
The magnitudes here come from a local demo, not a benchmark, and you should replace them with your own. But the reason the persist and aggregate stages are small is not tuning. It is that they are in-process calls rather than network round trips. The interesting figure is not any single millisecond. It is that the expensive boundary, the hop from broker to store, is simply not on the path being measured.
The architecture

The collapsed stack
Set the demo next to the four-service version and the difference is mostly things that are not there. No external broker: clients publish to live-scoring/{matchId}/events and Harper routes the publish straight into a resource. No bridge consumer: the message and the durable row are the same write. No cache tier: late joiners are hydrated by the retained record. No separate WebSocket gateway: the browser speaks MQTT over WebSockets on the same port that served the page, so there is no second host or port to configure.
A few smaller things fall out for free. Both tables expose CRUD over REST with no handler code. The backend has no build step, because resources/*.ts runs directly through Node's type stripping and only the frontend is bundled. And the scoring arithmetic lives in a plain module with no Harper import, so it unit-tests with node --test and no database at all.
Fewer hops is the latency win. Fewer systems that have to agree on the current score is usually the bigger one.
Where a dedicated broker still earns its place
This is a demo, and it is worth being blunt about that. It ships with no authentication: the ingest and read resources set checkPermission = false, and a local Harper install allows anonymous local access, which together are why the browser can read and publish with no login. Before anything shaped like this goes on a network, the tables and resources need real roles and those allowances have to come off.
The architecture has limits too, and they are the honest counterweight to the collapsed stack. A dedicated broker still makes sense when you need broker-scale fan-in from large device fleets, exactly-once (QoS 2) delivery semantics, broker-side retained and last-will behavior at scale, or federation and bridging across many brokers that are decoupled from any single data model. Harper's model fits the other case: when the thing you are broadcasting is a state change you also want to store, and you would rather not keep that state synchronized across four services to show it on a screen.
The score is going to change regardless of the architecture. What stays a choice is how many systems have to agree on the new number before a browser is allowed to display it.






.webp)


