Skip to content
DocsconceptsDocumentation

Choosing a data channel

Live query, stream, live topic, whisper, room or socket? Which one to use for which job.

Last updated:

Lunora gives a client several ways to receive data and several ways to send it. They are not interchangeable: each fits one kind of data. Start from what you are sending, not from the transport.

The short version

You want to send or receiveUseTransportTyped from the server?
Rows that stay fresh as data changesuseQuery on a queryWebSocketYes, through api.*
A writeuseMutation on a mutationClient callYes, through api.*
Side effects, third-party callsuseAction on an actionClient callYes, through api.*
Chunks of one long call, such as a model's tokensquery.stream() read with useStreamWebSocketYes, through api.*
Chunks of one HTTP requesthttpRoute.get(path).stream() read with useHttpStreamHTTP (SSE framing)Partly
A server-published event that is not a rowdefineLiveStream with useLiveTopic and its siblingsSSE, one connectionYes, through _generated/live.ts
An ephemeral message a client sends to other clientsWhispers (client.whisper, onWhisper to authorize)WebSocketNo
A typed room with server-side join, message and leave handlersdefineRoom and client.connectRoomWebSocketMessage validated by v
A dedicated socket at your own URL pathdefineWebSocket and client.connectWebSocket(path)WebSocketServer side only
Who is onlinePresence (a heartbeat mutation plus a live listPresent)WebSocketYes, through api.*
Access for a client that does not speak the Lunora protocolHTTP endpoints and the REST APIHTTPNo

Rows or events?

The question that settles most choices is whether the thing the client needs is a row or an event.

  • A row, or anything you can compute with a query: use a live query. It is typed, it runs under the caller's identity so rls() and ctx.auth apply, and it keeps working across shards and Durable Objects. A useQuery re-renders when a mutation changes the rows it read, with no publishing step to forget.
  • An event with no query behind it: "a comment was posted", "the build finished", "your export is ready". There is nothing to re-run, so use a live topic: the server publishes, and the browser follows several topics over one SSE connection.

If you can tell the two apart only with effort, use the live query. It is the typed, authorized, cross-shard path; a live topic is the way to deliver an event.

Live topics, whispers and rooms

These three all push "an arbitrary event", and they differ in who sends and over what.

SenderTransportAuthorization
Live topicThe server (ctx.live.publish)SSEonSubscribe per topic; default-deny
WhisperA clientWebSocketonWhisper; with none registered, every topic is open
RoomA client, through a server-side handlerWebSocketThe onWhisper seam plus the socket's identity

Reach for a whisper for cursors and typing indicators: it never touches SQLite or the change log. Reach for a room when the server should react to joins and messages. A live topic is for the case where the server decides something happened and a browser that only holds an SSE connection should hear about it.

Streams

Both stream kinds deliver the chunks of one call, not shared state.

  • query.stream() runs over the WebSocket and is read with useStream. Declare it durable and a dropped connection re-attaches to the same run instead of failing with STREAM_DISCONNECTED.
  • httpRoute.get(path).stream() runs on an HTTP route and is read with useHttpStream. A chunk wrapped in sseEvent carries an id, and the client replays it as Last-Event-ID to resume after a drop.

Write a row, then tell the page

The most common event is "this write happened, and a page that is not running that query should know". A mutation cannot publish, because ctx.live exists only on actions, HTTP actions, queue consumers and scheduled actions. Publishing is a non-deterministic fan-out, and a mutation can retry or roll back.

Schedule the publish from the mutation. A ctx.scheduler.runAfter(0, …) inside a mutation is held back until the transaction commits, so a rolled-back write sends no event.

import { internal } from "@/lunora/_generated/internal";
import { live } from "@/lunora/_generated/live";
import { internalAction, mutation, v } from "@/lunora/_generated/server";

export const addComment = mutation.input({ postId: v.id("posts"), text: v.string() }).mutation(async ({ ctx, args }) => {
    const commentId = await ctx.db.insert("comments", args);

    await ctx.scheduler.runAfter(0, internal.events.commentAdded, { postId: args.postId, commentId });
});

export const commentAdded = internalAction.input({ postId: v.id("posts"), commentId: v.id("comments") }).action(async ({ ctx, args }) => {
    await ctx.live?.publish(live.posts.post({ postId: args.postId }), { event: "comment_added", commentId: args.commentId });
});

This example reaches subscribers on the default shard, which is every subscriber in an app that does not use .shardBy(...). A scheduled action runs on the shard its job was scheduled with, or on the default shard when none was given, and the public ctx.scheduler.runAfter takes no shardKey. In a sharded app, publish from an HTTP action with { shardKey } instead; see Live topic streams.

If the page already shows the comments through a useQuery, you do not need the event at all: the query updates by itself. Publish only for pages that follow a post without that query.

Things to know before you rely on a live topic

  • At least once. A retried action publishes again, so a subscriber can see the same event twice. Put an id in the payload and ignore repeats.
  • Shard-local. A publish reaches the subscribers connected to the shard it names, and a scheduled or action publish reaches the shard it runs on (the shardKey the job was scheduled with, or the default shard). There is no fan-out across Durable Objects.
  • Sharded apps. A scheduled action publishes on the shard it runs on, which is the default shard unless the job was scheduled with a shardKey, and the public scheduler has no such option. Subscribers connected to another shard do not hear it. Publish from an HTTP action with { shardKey } in a sharded app.
  • Typed from the declaration. You declare each topic's key and payload on the stream, and codegen writes _generated/live.ts with a typed reference per topic. The publisher, the shard and the hook all use it, so a typo in a topic or a payload of the wrong shape is a type error, and a publish that gets past the types anyway (a queue message body, say) is refused by the payload validator.
  • You mount the routes. defineLiveStream returns handlers; you mount them on your HTTP router.
  • Stock React Native fetch does not stream, so events do not arrive without a streaming fetch such as expo/fetch.

See Live topic streams for the full reference, including authorization defaults and platform support.