Push-based query
The default
PersistenceQuery is
poll-based: live streams check the journal every
pollIntervalMs (default 1 s) for new events. Fine for most
projections, but introduces up to 1 second of latency per event.
For lower latency, the framework adds push delivery via an
in-process JournalEventBus:
PersistentActor ProjectionActor │ │ │ persist(event) │ ▼ │ Journal │ │ │ ├─ store event │ ├─ publish to JournalEventBus ───────────► wake immediately │ │ ▼ │ (next poll would catch it eventually) handle event │ save offsetThe bus delivers in single-digit milliseconds instead of the poll interval. At-least-once semantics are preserved — if the subscriber misses a publication (start-up race, crash), the next poll picks it up.
When this matters
Section titled “When this matters”Sub-poll-interval delivery is only valuable in one specific case:
- The
PersistentActorand theProjectionActorare on the same node. - The latency from
persist()to projection handler is user-visible.
Across nodes (writer on node-A, projection on node-B), the bus doesn’t help — it’s an in-process bus. Across nodes, you’re bounded by either polling cadence or the journal’s own replication / pub/sub layer (Cassandra’s CDC, Postgres logical replication, etc.).
For most apps, the default polling is fine — 1-second projection lag is rarely a problem. Reach for push-based when:
- A real-time UI subscribes to a projection.
- A cluster-singleton-coordinator needs to react to its own persistence events in real-time.
- The projection drives compensating actions in a saga, where poll lag stacks across steps.
How to enable it
Section titled “How to enable it”const projection = ProjectionActor.byTag<E>({ name: 'realtime-balance', tag: 'account', query: new SqliteQuery({ path: '...' }), liveOptions: { push: true, // enable bus subscription pollIntervalMs: 5_000, // fallback poll cadence — can be high }, async handle(event) { /* ... */ },});With push: true:
- The projection also subscribes to the journal’s in-process bus.
- On bus delivery, the projection wakes immediately, queries the journal for the new event(s), and processes them.
- The poll still runs as a safety net at
pollIntervalMs— set to 5-30 seconds since push handles the hot path.
The fallback poll catches:
- Events the bus missed (rare — subscription races at start-up).
- Events from cross-process writers (not delivered via the in-process bus).
- Events your handler crashed on and needs to retry on restart.
The mechanism
Section titled “The mechanism”// Inside the journal, on each successful append:this.events.publish(persistentEvent);
// Inside the projection, on each subscription:this.events.subscribe((event) => { if (matchesFilter(event)) wakeUpAndProcess();});The bus is a simple in-process pub/sub — no network, no serialization. Publish is synchronous; subscribers fire on the same tick.
For the SQLite + in-memory journals, the bus is wired up
automatically. For custom journals, your Journal implementation
should call this.events.publish(...) on each successful append
to enable push delivery to in-process consumers.
Out of scope
Section titled “Out of scope”Push delivery is strictly in-process. For cross-node push delivery:
- The framework’s DistributedPubSub handles general-purpose cluster pub/sub. You’d manually publish on events you want streamed cluster-wide.
- The Cassandra journal has the option of using CDC for cross-cluster delivery, but it’s not wired in by default.
For “real-time projections across nodes,” the simplest pattern is:
- The
PersistentActor(on whichever node hosts it) also publishes via DistributedPubSub when persisting (custom code on top of the per-persist callback). - Subscribers on any node receive the notification and trigger their projections.
When NOT to use it
Section titled “When NOT to use it”Where to next
Section titled “Where to next”- Persistence overview — the bigger picture.
- Persistence query — the underlying API.
- Projections — what consumes the bus.
- DistributedPubSub — for cross-node real-time fan-out.