Skip to content
DocsconceptsDocumentation

Real-time

How subscriptions, deltas, and hibernation fit together.

Last updated:

Every Lunora query doubles as a subscription. Calling useQuery(api.x.y, args) in React opens a multiplexed WebSocket to your Worker, registers the query with the resolving Durable Object, and re-renders the component whenever a matching mutation broadcasts a delta.

import { api } from "@/lunora/_generated/api";
import { useMutation, useQuery } from "@lunora/react";

export const Chat = ({ channelId }: { channelId: string }) => {
    const messages = useQuery(api.messages.list, { channelId });
    const send = useMutation(api.messages.send);

    if (!messages) return <p>Loading…</p>;
    return (
        <ul>
            {messages.map((m) => (
                <li key={m._id}>{m.text}</li>
            ))}
        </ul>
    );
};

Hibernation

Subscriptions are stored on the WebSocket via state.serializeAttachment(...). When the Durable Object hibernates (no traffic for ~10s) the WebSocket is suspended without losing the subscription registry; the next message wakes it up and re-routes deltas exactly where they were going.

This is why your DO bill drops to near-zero between bursts: idle subscribers pay for storage, not compute.

Delta routing

A mutation that writes to messages calls this.broadcastDelta({ table: "messages", ... }). Each socket carries a SocketAttachment mapping subId → SubscriptionQuery. Only sockets with a registered subscription whose query.table matches receive the delta, scoped further by index predicate.

Reconnect

The client (@lunora/client) uses exponential backoff with jitter and resumes by bookmark: it sends the last delta sequence it acknowledged so the server can replay anything that was missed during the disconnect.

Durable streams

A .stream() procedure is ephemeral by default: the producer belongs to the socket that opened it, so closing the tab ends the run and a reconnect starts over. That is right for a progress ticker and wrong for anything expensive: a model's answer must not disappear because someone refreshed.

Declare the stream durable and the run stops belonging to the socket:

export const answer = query.input({ threadId: v.id("threads"), prompt: v.string() }).stream(
    async function* ({ ctx, args }) {
        for await (const token of callTheModel(args.prompt)) {
            yield token;
        }
    },
    { durable: true },
);

Three things change:

  • Every chunk is persisted before it is sent, under a monotonic seq. A reconnecting client replays the frames it missed out of the shard's SQLite and then continues live; the consumer's for await never sees the interruption.
  • The producer outlives the socket. Close the tab mid-run and the run finishes anyway; the transcript is waiting when you come back.
  • A live run is shared. Its identity is (identity, functionPath, args), so a second tab of the same signed-in user attaches to the run already in flight instead of paying for a second generation. A different identity always gets its own run. A finished run is not shared: a later caller asking the same question gets a fresh answer, because a transcript is the record of one execution, not a cached response.

On the client, opt in per call so the reconnect resumes rather than fails:

const { chunks, status } = useStream(api.chat.answer, { threadId, prompt }, { durable: true });

Transcripts are trimmed after 24 hours by default, per procedure ({ durable: { ttlMs } } to change it), and a run is capped at 50,000 chunks so a runaway generator cannot fill the shard's storage.

If the Durable Object is evicted mid-run, what happens depends on who asks next. A client resuming that transcript gets STREAM_INTERRUPTED: its tail cannot be spliced back on, and re-generating would duplicate what it already has. A client asking fresh simply gets a new run: the dead one has no claim on its key. When you need a producer that survives eviction itself, the run belongs in a workflow.

Why no MQTT / Pub/Sub

Real-time fan-out in Lunora is entirely Durable-Object-based: hibernated WebSocket subscriptions on ShardDO, type-safe and integrated end-to-end with your queries, with no external broker to run. Cloudflare Pub/Sub (an MQTT broker) is therefore a non-goal. It is in beta with gated onboarding and no Worker binding, and the only capability it adds over the DO path is native-MQTT device ingest (IoT clients speaking MQTT directly), a narrow niche. We'll revisit only if Pub/Sub reaches GA and a concrete need to ingest from native-MQTT devices appears; until then there is nothing to configure.