Getting started
Add live data and presence to your app in five minutes - install RealtimePlugin, wire identity, drop in one script tag, and push your first event.
Getting started with realtime
umbral-realtime is the "Supabase feeling" for umbral: subscribe to live data and presence from the browser, push from the server with one line, and stay safe by default. There is no socket bookkeeping, no polling, and no serializer code to write.
This page is the five-minute path: install the plugin, wire identity, add the client script, and send your first end-to-end event.
One browser holds one connection no matter how many tabs or subscriptions are open, the server is proven to 10,000 connections on a single instance, and nothing is broadcast unless you explicitly opt in. The defaults are the safe path.
1. Install the plugin
Add umbral-realtime to your binary crate and register the plugin on the app builder. With nothing else configured you get both transports mounted (GET /realtime/sse, GET /realtime/ws) and the ambient Realtime handle for sending.
use umbral::prelude::*;use umbral_realtime::RealtimePlugin; #[tokio::main]async fn main() -> anyhow::Result<()> { let app = App::builder() // ... your settings, database, models, routes ... .plugin(RealtimePlugin::new()) .build()?; umbral_cli::dispatch(app).await}2. Wire identity (so the framework knows who's connecting)
By default every connection is anonymous - the identity resolver returns None. That's fine for a fully public feed, but if you want to gate rooms by user or use presence, the framework needs to know who's connecting. The resolved identity is user_id: Option<String> - the user's primary key rendered to its canonical string, so it works for any PK type (i64 → "42", uuid → "550e8400-…").
If you use umbral-auth, enable the auth feature and call with_auth_sessions(). It identifies the connecting user from the session cookie at the SSE/WS handshake:
RealtimePlugin::new() .with_auth_sessions() // requires the `auth` cargo featureFor a custom scheme (JWT, API key), supply your own async resolver - see Gating access.
Forget this step and everyone is anonymous: user_id is always None, so any policy that checks the user can never grant a private room, and presence shows no one. If your app has logins, wire with_auth_sessions() (or a custom resolver) from the start.
3. Add the client script
The plugin serves a tiny browser helper at GET /realtime/client.js. Add one script tag to your template; it exposes the umbral.realtime namespace.
<><script src="/realtime/client.js"></script></>There is nothing to bundle or import - the script registers window.umbral.realtime with subscribe, model, and presence.
Remounting. The realtime routes default to /realtime/.... Call RealtimePlugin::new().at("/rt") to remount them (/rt/sse, /rt/ws, /rt/worker.js, /rt/client.js). The served JS templates its own URLs off the base, so you only update the `
Here is a full round trip on the public public:lobby group - allowed by the default policy with no extra wiring.
Subscribe in the browser
- <script src="/realtime/client.js"></script>+ <script src="/realtime/client.js"></script><script> const sub = umbral.realtime.subscribe("public:lobby", { ping: (data) => console.log("got ping", data), }); // later: sub.unsubscribe();</script>subscribe(groups, handlers) returns { unsubscribe }. handlers is keyed by event name - here we listen for the "ping" event.
Push from the server
Anywhere in a request handler, a signal handler, or a background task:
use umbral_realtime::Realtime;use serde_json::json; Realtime::to_group("public:lobby") .send("ping", &json!({ "message": "hello room" })) .await;.send(event_name, &data) serializes data to JSON and pushes it to every connection in the group. It's fire-and-forget: it never blocks on socket I/O, so it's safe in a request handler.
That's the whole loop. Every browser in public:lobby logs got ping { message: "hello room" }.
Realtime::to_group(...).send(...) is a no-op when RealtimePlugin isn't installed, so you can call it unconditionally and an app (or test) that doesn't wire realtime simply ignores it instead of panicking. Use Realtime::is_installed() if you need to check.
The three ways to target a send
| Call | Reaches |
|---|---|
Realtime::to_user(id.to_string()) | every live connection authenticated as that user (id is the PK string) |
Realtime::to_group(group) | every connection that joined that group/room |
Realtime::broadcast() | every connection |
Each returns a Target; call .send(event_name, &data).await on it.