Skip to content

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 offset

The 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.

Sub-poll-interval delivery is only valuable in one specific case:

  • The PersistentActor and the ProjectionActor are 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.
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.
// 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.

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.