Click Below to Get the Code

Browse, clone, and build from real-world templates powered by Harper.
Blog
GitHub Logo

The Topic Is the Record: Real-Time Fan-Out Without a Broker, a Bridge, and a Cache

Most real-time apps need a broker, a bridge, and a cache to keep state in sync. This is a live MQTT scoring demo on Harper, where the topic and the database record are the same write.
Blog

The Topic Is the Record: Real-Time Fan-Out Without a Broker, a Bridge, and a Cache

Austin Akers
Head of Developer Relations
at Harper
August 28, 2026
Austin Akers
Head of Developer Relations
at Harper
August 28, 2026
Austin Akers
Head of Developer Relations
at Harper
August 28, 2026
August 28, 2026
Most real-time apps need a broker, a bridge, and a cache to keep state in sync. This is a live MQTT scoring demo on Harper, where the topic and the database record are the same write.
Austin Akers
Head of Developer Relations

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.

Live App

GitHub

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.

Live App

GitHub

Most real-time apps need a broker, a bridge, and a cache to keep state in sync. This is a live MQTT scoring demo on Harper, where the topic and the database record are the same write.

Download

White arrow pointing right
Most real-time apps need a broker, a bridge, and a cache to keep state in sync. This is a live MQTT scoring demo on Harper, where the topic and the database record are the same write.

Download

White arrow pointing right
Most real-time apps need a broker, a bridge, and a cache to keep state in sync. This is a live MQTT scoring demo on Harper, where the topic and the database record are the same write.

Download

White arrow pointing right

Explore Recent Resources

Media Coverage
GitHub Logo

Harper Argues Against the Multi-System Stack and Releases 5.2

InfoQ examines benchmark results comparing co-located and serverless architectures, highlighting faster personalized-data paths, serverless advantages under heavy fan-out, and the performance implications of eliminating network hops between services at scale.
Media Coverage
InfoQ examines benchmark results comparing co-located and serverless architectures, highlighting faster personalized-data paths, serverless advantages under heavy fan-out, and the performance implications of eliminating network hops between services at scale.
Renato Losio, Staff Editor at InfoQ
Renato Losio
InfoQ Staff Editor
Media Coverage

Harper Argues Against the Multi-System Stack and Releases 5.2

InfoQ examines benchmark results comparing co-located and serverless architectures, highlighting faster personalized-data paths, serverless advantages under heavy fan-out, and the performance implications of eliminating network hops between services at scale.
Renato Losio
Aug 2026
Media Coverage

Harper Argues Against the Multi-System Stack and Releases 5.2

InfoQ examines benchmark results comparing co-located and serverless architectures, highlighting faster personalized-data paths, serverless advantages under heavy fan-out, and the performance implications of eliminating network hops between services at scale.
Renato Losio
Media Coverage

Harper Argues Against the Multi-System Stack and Releases 5.2

InfoQ examines benchmark results comparing co-located and serverless architectures, highlighting faster personalized-data paths, serverless advantages under heavy fan-out, and the performance implications of eliminating network hops between services at scale.
Renato Losio
Blog
GitHub Logo

Faster by Doing Less: How Harper 5.2 Engineers Database Performance

Harper 5.2 attacks database performance on three fronts: a per-worker record cache validated through lock-free atomic version slots, isolated commit scheduling to keep database writes off the application thread, and query planner improvements that route through the shortest available data path.
Cache
Blog
Harper 5.2 attacks database performance on three fronts: a per-worker record cache validated through lock-free atomic version slots, isolated commit scheduling to keep database writes off the application thread, and query planner improvements that route through the shortest available data path.
Person with very short blonde hair wearing a light gray button‑up shirt, standing with arms crossed and smiling outdoors with foliage behind.
Kris Zyp
SVP of Engineering
Blog

Faster by Doing Less: How Harper 5.2 Engineers Database Performance

Harper 5.2 attacks database performance on three fronts: a per-worker record cache validated through lock-free atomic version slots, isolated commit scheduling to keep database writes off the application thread, and query planner improvements that route through the shortest available data path.
Kris Zyp
Aug 2026
Blog

Faster by Doing Less: How Harper 5.2 Engineers Database Performance

Harper 5.2 attacks database performance on three fronts: a per-worker record cache validated through lock-free atomic version slots, isolated commit scheduling to keep database writes off the application thread, and query planner improvements that route through the shortest available data path.
Kris Zyp
Blog

Faster by Doing Less: How Harper 5.2 Engineers Database Performance

Harper 5.2 attacks database performance on three fronts: a per-worker record cache validated through lock-free atomic version slots, isolated commit scheduling to keep database writes off the application thread, and query planner improvements that route through the shortest available data path.
Kris Zyp
News
GitHub Logo

Harper Recognized as an Honorable Mention in the 2026 Gartner® Magic Quadrant™ for Cloud-Native Application Platforms

Harper’s inclusion highlights a broader shift toward integrated cloud-native platforms that unify data, caching, messaging, and application logic. The article explains how Harper’s distributed runtime and managed infrastructure reduce operational complexity, improve performance, and support modern applications and AI agents that require fast, reliable access to state, events, and tools.
Announcement
News
Harper’s inclusion highlights a broader shift toward integrated cloud-native platforms that unify data, caching, messaging, and application logic. The article explains how Harper’s distributed runtime and managed infrastructure reduce operational complexity, improve performance, and support modern applications and AI agents that require fast, reliable access to state, events, and tools.
Person with short dark hair and moustache, wearing a colorful plaid shirt, smiling outdoors in a forested mountain landscape.
Aleks Haugom
Senior Manager of GTM
News

Harper Recognized as an Honorable Mention in the 2026 Gartner® Magic Quadrant™ for Cloud-Native Application Platforms

Harper’s inclusion highlights a broader shift toward integrated cloud-native platforms that unify data, caching, messaging, and application logic. The article explains how Harper’s distributed runtime and managed infrastructure reduce operational complexity, improve performance, and support modern applications and AI agents that require fast, reliable access to state, events, and tools.
Aleks Haugom
Aug 2026
News

Harper Recognized as an Honorable Mention in the 2026 Gartner® Magic Quadrant™ for Cloud-Native Application Platforms

Harper’s inclusion highlights a broader shift toward integrated cloud-native platforms that unify data, caching, messaging, and application logic. The article explains how Harper’s distributed runtime and managed infrastructure reduce operational complexity, improve performance, and support modern applications and AI agents that require fast, reliable access to state, events, and tools.
Aleks Haugom
News

Harper Recognized as an Honorable Mention in the 2026 Gartner® Magic Quadrant™ for Cloud-Native Application Platforms

Harper’s inclusion highlights a broader shift toward integrated cloud-native platforms that unify data, caching, messaging, and application logic. The article explains how Harper’s distributed runtime and managed infrastructure reduce operational complexity, improve performance, and support modern applications and AI agents that require fast, reliable access to state, events, and tools.
Aleks Haugom