Part 1 covered the Axum basics. Part 2 covered how to coexist with a Rails-owned Postgres database without breaking ActiveRecord’s invariants. Now we’re going to do something that Rails actively suffers at and that Tokio is built for: streaming responses to many connected clients at once.
If you’ve ever tried to do live notifications in Rails with ActionCable or long-polling, you know the failure modes: Puma worker counts pinned to connection counts, GIL contention, memory growth, dropped connections under load. Tokio sidesteps all of it because async tasks aren’t threads; you can hold tens of thousands of open SSE connections on a single Rust process without flinching.
This post is going to be heavily code-driven. Same three passes: beginner (what streaming HTTP even is, your first SSE endpoint), intermediate (broadcasting to many clients, reconnection, heartbeats), advanced (Postgres LISTEN/NOTIFY as a source, multi-node fanout, backpressure, graceful shutdown).
A quick mental model: how streaming HTTP works
Before any code, let’s level-set. A “normal” HTTP response looks like:
HTTP/1.1 200 OK
Content-Length: 142
Content-Type: application/json
{"id": 1, "name": "Ada"}
The server tells the client up front how many bytes are coming. Done.
A streaming response uses chunked transfer encoding:
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/event-stream
19\r\n
data: {"order": 42}\n\n\r\n
17\r\n
data: {"order": 43}\n\n\r\n
0\r\n
\r\n
There’s no Content-Length. Instead, each chunk is preceded by its length in hex, and a 0-length chunk signals the end. The connection stays open the whole time. The server can push more data whenever it has it.
Three flavors of streaming you’ll encounter:
| Tech | Direction | Built on | When to use |
|---|---|---|---|
| Server-Sent Events (SSE) | server → client only | HTTP/1.1 | Notifications, live feeds, progress, log tailing |
| WebSockets | bidirectional | TCP, upgraded from HTTP | Chat, collaborative editing, games |
| Long polling | request/response loop | plain HTTP | Legacy clients that can’t do either |
SSE is dramatically underrated. It’s plain HTTP, works through almost every proxy, the browser has a native EventSource API that handles reconnection for you, and it’s perfect for the 90% of “real-time” use cases that are actually just “server pushes events to client.” We’ll use it as the default and only reach for WebSockets when bidirectionality is non-negotiable.
Beginner: Your first SSE endpoint
Start from the project we built in Part 1. Add two dependencies:
[dependencies]
# ...existing deps from Part 1...
futures = "0.3" # Stream trait + combinators
tokio-stream = "0.1" # adapters from tokio types into Streams
futures::Stream is the async equivalent of Iterator. An iterator gives you next() synchronously; a stream gives you .next().await. That’s the entire conceptual difference.
A minimal SSE endpoint:
use axum::{
response::sse::{Event, Sse},
routing::get,
Router,
};
use futures::stream::{self, Stream};
use std::{convert::Infallible, time::Duration};
use tokio_stream::StreamExt as _; // for `.throttle()`
async fn sse_clock() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
// `stream::repeat_with` calls a closure forever, producing one value per iteration.
// We map each tick to an SSE `Event` carrying the current timestamp.
let stream = stream::repeat_with(|| {
let now = chrono::Utc::now().to_rfc3339();
Event::default().event("tick").data(now)
})
// Each `Event` is wrapped in Ok(...) because Axum's SSE type wants
// `Stream<Item = Result<Event, E>>`. `Infallible` means "this can't fail".
.map(Ok)
// Don't produce events faster than once per second.
.throttle(Duration::from_secs(1));
Sse::new(stream)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/sse/clock", get(sse_clock));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Test it from your terminal:
curl -N http://localhost:3000/sse/clock
# event: tick
# data: 2026-06-02T10:00:01.123Z
#
# event: tick
# data: 2026-06-02T10:00:02.124Z
# ...
The -N flag tells curl not to buffer. Without it, curl waits for the full response (which never comes) before printing anything.
What just happened, line by line
Sse<impl Stream<...>>: Axum’s SSE response type. You hand it a stream, and Axum takes care of settingContent-Type: text/event-stream, formatting eachEventcorrectly, and writing it out as it arrives.Event::default().event("tick").data(now): builds one SSE message.event(...)sets the event name (clients can subscribe withaddEventListener("tick", ...));data(...)sets the payload..map(Ok): converts eachEventintoResult<Event, _>. Axum lets streams emit errors that close the connection; here we promise we never will, viaInfallible..throttle(Duration::from_secs(1)): slows the stream to at most one event per second. Without this,repeat_withwould saturate a CPU producing events as fast as it can.
The browser side
Consuming SSE from JavaScript is built in:
const es = new EventSource("/sse/clock");
es.addEventListener("tick", (e) => {
console.log("got tick:", e.data);
});
es.onerror = () => console.log("connection lost, EventSource will retry");
EventSource automatically reconnects with exponential backoff if the connection drops. You don’t write that code. You don’t even have to think about it.
Beginner best practices
- Always set an
event:name. A stream of unnamed events is fine but limits you to one subscriber type per endpoint. Naming them lets oneEventSourcehandle many event types. - Send heartbeats. We’ll cover this properly in the next section, but: most proxies (nginx, ELB, Cloudflare) close idle connections after 30–60 seconds. A periodic no-op event keeps the connection alive.
- Use
Sse::keep_alive. Axum has built-in keepalive that sends:keepalive\n\ncomments (the:prefix means “comment, ignore”). One line, no app-level logic.
use axum::response::sse::KeepAlive;
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text(":keepalive"),
)
Intermediate: Broadcasting one source to many clients
The clock example is uninteresting because each connection gets its own stream. The real use case is the opposite: one event source, many connected clients. A new order comes in; every dashboard tab subscribed to /sse/orders should see it within milliseconds.
The right tool for this is tokio::sync::broadcast, a multi-producer, multi-consumer channel where every active subscriber receives every message.
Step 1: The shared broadcast channel
use axum::{
extract::State,
response::sse::{Event, KeepAlive, Sse},
routing::{get, post},
Json, Router,
};
use futures::stream::Stream;
use serde::{Deserialize, Serialize};
use std::{convert::Infallible, sync::Arc, time::Duration};
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt as _;
#[derive(Clone, Debug, Serialize, Deserialize)]
struct OrderEvent {
id: String,
customer_id: String,
total_cents: i64,
kind: String, // "created", "paid", "shipped", ...
}
#[derive(Clone)]
struct AppState {
// Wrapped in Arc so cloning the state is cheap.
// `broadcast::Sender` itself is already cheap to clone (it's Arc-backed),
// but bundling everything behind one Arc keeps state ergonomic.
events: Arc<broadcast::Sender<OrderEvent>>,
}
A few things worth unpacking:
broadcast::Sender<T>is the producer side. Every clone of the sender produces into the same channel. Every call tosubscribe()produces a newReceiverthat gets every message sent from that moment on.- The channel has a fixed capacity (we’ll set it shortly). If a receiver is slow and falls behind by more than the capacity, it gets a
Lagged(n)error telling it how many messages it missed. The channel does not block producers. This is important: a slow client can never stall the server.
Step 2: Wire it up in main
#[tokio::main]
async fn main() {
// Capacity of 1024: a slow subscriber can fall up to 1024 events behind
// before it starts seeing `Lagged` errors. Tune to your event rate.
let (tx, _) = broadcast::channel::<OrderEvent>(1024);
let state = AppState {
events: Arc::new(tx),
};
let app = Router::new()
.route("/sse/orders", get(stream_orders))
.route("/orders", post(create_order))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
The (tx, _) pattern: broadcast::channel returns (Sender, Receiver). We don’t need the initial receiver (we’ll create one per connection via tx.subscribe()), so we discard it with _.
Step 3: The producing endpoint
#[derive(Deserialize)]
struct CreateOrder {
customer_id: String,
total_cents: i64,
}
async fn create_order(
State(state): State<AppState>,
Json(payload): Json<CreateOrder>,
) -> Json<OrderEvent> {
let event = OrderEvent {
id: uuid::Uuid::new_v4().to_string(),
customer_id: payload.customer_id,
total_cents: payload.total_cents,
kind: "created".to_string(),
};
// Broadcast to every subscriber. `send` returns the number of receivers
// that got the message. We ignore the result — if there are zero
// subscribers right now, that's fine; we still serve the HTTP request.
let _ = state.events.send(event.clone());
Json(event)
}
The let _ = ... pattern is idiomatic Rust for “I’m intentionally ignoring this result.” It’s noisier than implicitly discarding (some other languages let you do that silently), but you’ll be glad for it when you read code six months later and immediately know “yes, the author meant to drop that.”
Step 4: The streaming endpoint
async fn stream_orders(
State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
// Subscribe to the broadcast channel. From this point on, this receiver
// gets every message that's sent — and *only* messages sent after now.
// Messages sent before this call are not replayed.
let rx = state.events.subscribe();
// `BroadcastStream` adapts a `broadcast::Receiver` into a `futures::Stream`.
// The items are `Result<T, BroadcastStreamRecvError>` because of the
// possibility of `Lagged` (slow subscriber fell behind).
let stream = BroadcastStream::new(rx)
.filter_map(|res| async move {
match res {
Ok(event) => {
// Successfully got an event. Serialize to JSON.
let json = serde_json::to_string(&event).ok()?;
Some(Ok(Event::default().event(&event.kind).data(json)))
}
Err(err) => {
// Receiver fell behind. Log it and emit a special SSE event
// so the client knows it missed messages and can re-fetch
// state if it needs to.
tracing::warn!(error = ?err, "sse subscriber lagged");
Some(Ok(Event::default()
.event("lagged")
.data(format!("{err}"))))
}
}
});
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text(":keepalive"),
)
}
Several things worth a beat:
filter_map+async move { ... }is the async equivalent ofiter.filter_map(...). The closure returnsOption<T>:Somekeeps the item,Nonedrops it. We use it so that a serialization failure silently drops the event instead of killing the whole stream.Laggedis not a fatal error. When a client is slow and the channel wraps, the receiver gets aLagged(n)instead of the message. Treat it as a soft event: tell the client, let them refetch state, keep the connection.- Per-connection ownership. Each connection gets its own
rx. When the HTTP connection closes (client disconnects, network blip, whatever), the future running the stream is dropped, therxis dropped, and Tokio cleans everything up automatically. No explicit unsubscribe.
Step 5: Test the broadcast
In one terminal:
curl -N http://localhost:3000/sse/orders
In another:
curl -X POST http://localhost:3000/orders \
-H 'content-type: application/json' \
-d '{"customer_id": "c1", "total_cents": 2500}'
The first terminal should immediately print:
event: created
data: {"id":"...","customer_id":"c1","total_cents":2500,"kind":"created"}
Open ten more curl-streaming terminals. Issue one POST. Watch all ten light up at once. That’s broadcast.
Per-subscriber filtering
What if a subscriber only wants events for a specific customer? Add a filter:
use axum::extract::Query;
#[derive(Deserialize)]
struct StreamFilter {
customer_id: Option<String>,
}
async fn stream_orders(
State(state): State<AppState>,
Query(filter): Query<StreamFilter>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let rx = state.events.subscribe();
let stream = BroadcastStream::new(rx).filter_map(move |res| {
let filter = filter.customer_id.clone();
async move {
let event = res.ok()?;
// If the subscriber asked for a specific customer, skip others.
if let Some(cid) = &filter {
if &event.customer_id != cid {
return None;
}
}
let json = serde_json::to_string(&event).ok()?;
Some(Ok(Event::default().event(&event.kind).data(json)))
}
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
Now GET /sse/orders?customer_id=c1 gets only c1’s events. The filtering happens after the broadcast: every subscriber still receives every message internally; we just don’t forward unwanted ones to the HTTP body. For high-cardinality filtering (millions of possible filter values, a tiny percentage hitting any given event), you’d push the filter upstream into the producer. We’ll see how with Postgres LISTEN/NOTIFY in the next section.
Reconnection with Last-Event-ID
When EventSource reconnects, the browser sends a Last-Event-ID header with the ID of the last event it processed. If your events have IDs and you have a way to replay them, you can resume:
use axum::http::HeaderMap;
async fn stream_orders(
State(state): State<AppState>,
headers: HeaderMap,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let last_event_id = headers
.get("last-event-id")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Build a stream that first replays events since `last_event_id`,
// then transitions to live broadcast.
let replay = match last_event_id {
Some(id) => fetch_events_since(&state.db, &id).await.unwrap_or_default(),
None => Vec::new(),
};
let replay_stream = futures::stream::iter(replay).map(|e| Ok(event_for(e)));
let live_stream = BroadcastStream::new(state.events.subscribe())
.filter_map(|res| async move { Some(Ok(event_for(res.ok()?))) });
// `chain` plays the first stream to completion, then the second.
let combined = replay_stream.chain(live_stream);
Sse::new(combined)
}
fn event_for(e: OrderEvent) -> Event {
Event::default()
.id(&e.id) // SSE `id:` field — what the browser stores
.event(&e.kind)
.data(serde_json::to_string(&e).unwrap_or_default())
}
The id(...) call is the critical bit: without it, the browser has nothing to send back as Last-Event-ID next time.
There’s a subtle race here: between fetching the replay and subscribing to live, a new event could arrive that’s in neither. Production-grade solutions either (a) subscribe first, buffer, then replay and de-dup, or (b) include a monotonic sequence number with every event and rely on the database to be the source of truth across reconnects. For most apps, the simple approach above is good enough.
Advanced: Real event sources and multi-node fanout
Up to here, the event source has been POST /orders. In production, that’s almost never the case. Events come from background jobs, database writes by other services, scheduled work, webhooks. And your Rust service almost certainly runs on more than one pod.
Postgres LISTEN/NOTIFY as the source of truth
Postgres has a built-in pub/sub mechanism. Any session can NOTIFY channel, 'payload' and any listening session gets it. This is brilliant for our case: Rails (or anything else) can write to the database, fire a NOTIFY, and our Rust service streams it to all connected SSE clients.
The Rails side, in a model callback:
class Order < ApplicationRecord
after_commit :publish_event
private
def publish_event
payload = { id: id, customer_id: customer_id, total_cents: total_cents, kind: "created" }
ActiveRecord::Base.connection.execute(
"NOTIFY order_events, #{ActiveRecord::Base.connection.quote(payload.to_json)}"
)
end
end
The Rust side, a background task that listens and feeds the broadcast channel:
use sqlx::postgres::{PgListener, PgPool};
async fn listen_for_pg_notifications(
pool: PgPool,
tx: Arc<broadcast::Sender<OrderEvent>>,
) -> anyhow::Result<()> {
let mut listener = PgListener::connect_with(&pool).await?;
listener.listen("order_events").await?;
tracing::info!("listening for postgres NOTIFY on `order_events`");
loop {
// `recv()` awaits the next notification. It also handles reconnects
// transparently — if the underlying connection drops, PgListener
// reconnects and re-issues the LISTEN.
match listener.recv().await {
Ok(notification) => {
match serde_json::from_str::<OrderEvent>(notification.payload()) {
Ok(event) => {
// Push into the in-memory broadcast.
// If there are zero subscribers, send returns Err — that's fine.
let _ = tx.send(event);
}
Err(err) => {
tracing::error!(
error = ?err,
payload = notification.payload(),
"failed to parse pg notification"
);
}
}
}
Err(err) => {
// sqlx 0.8 PgListener handles reconnect internally, so an
// error here means something fundamental is broken.
tracing::error!(error = ?err, "pg listener stopped");
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
}
}
Spawn it in main:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// ...tracing init...
let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let (tx, _) = broadcast::channel::<OrderEvent>(1024);
let tx = Arc::new(tx);
// Spawn the listener as an independent task. It runs for the lifetime
// of the process. If it ever returns (it shouldn't), we log and continue.
tokio::spawn({
let tx = tx.clone();
let pool = pool.clone();
async move {
if let Err(err) = listen_for_pg_notifications(pool, tx).await {
tracing::error!(error = ?err, "pg notification task exited");
}
}
});
let state = AppState { events: tx };
let app = Router::new()
.route("/sse/orders", get(stream_orders))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Now the picture is:
Rails: Order#create ─after_commit─▶ NOTIFY order_events, '{...}'
│
▼
Postgres ─────────────────────────────────────────┐
│
▼
Rust: PgListener.recv ─▶ broadcast::Sender ─▶ [Sse subscriber 1]
─▶ [Sse subscriber 2]
─▶ [Sse subscriber N]
Beautiful: Rails never needs to know about your Rust service. The database is the integration point. Add a Python service later that wants the same events? It does LISTEN order_events too.
Going multi-node
You’re running three pods of the Rust service behind a load balancer. A client connects to pod A. The event comes in to pod B (or fires from Rails, hitting all three pods). Does the client get it?
With Postgres LISTEN/NOTIFY, yes: every pod is listening on the same channel. Each pod gets every NOTIFY. Each pod broadcasts to its own connected clients. The fanout happens per pod, but every pod sees every event.
This is one of the most underrated benefits of LISTEN/NOTIFY: you get multi-node fanout for free, with no Redis, no Kafka, no NATS, no extra moving piece. Postgres pushes the message to every connected listener.
The catch: NOTIFY has an 8KB payload limit. For larger events, NOTIFY a row ID and have the listeners SELECT the full row:
# Rails
ActiveRecord::Base.connection.execute(
"NOTIFY order_events, #{ActiveRecord::Base.connection.quote(id.to_s)}"
)
// Rust
match listener.recv().await {
Ok(notification) => {
let order_id: Uuid = notification.payload().parse()?;
let event = fetch_order_event(&pool, order_id).await?;
let _ = tx.send(event);
}
// ...
}
You pay one extra SELECT per event, but you can send arbitrarily large payloads.
When NOTIFY isn’t enough: Redis pub/sub or NATS
LISTEN/NOTIFY scales remarkably far (tens of thousands of events/sec per Postgres instance), but it has limits:
- It’s not persistent. A NOTIFY sent while a listener is reconnecting is lost.
- It’s not partitioned. Every listener gets every notification.
- It puts load on your primary DB, which you may not want.
For higher scale or stricter durability, swap the source. Redis pub/sub keeps the same shape:
async fn listen_for_redis(
client: redis::Client,
tx: Arc<broadcast::Sender<OrderEvent>>,
) -> anyhow::Result<()> {
let mut pubsub = client.get_async_connection().await?.into_pubsub();
pubsub.subscribe("order_events").await?;
let mut stream = pubsub.on_message();
while let Some(msg) = stream.next().await {
let payload: String = msg.get_payload()?;
if let Ok(event) = serde_json::from_str::<OrderEvent>(&payload) {
let _ = tx.send(event);
}
}
Ok(())
}
The handler-side code (stream_orders) doesn’t change at all. That’s the value of going through the broadcast channel: the source can be swapped without touching the HTTP layer.
For durable, replayable streams with consumer groups, NATS JetStream or Kafka are the next step up. The pattern is identical.
Backpressure: what happens when a client is slow
A slow client can’t slow down your server’s event production: broadcast::Sender::send never blocks. But the slow client’s Receiver will fall behind, eventually overflowing the channel’s capacity and getting Lagged(n) errors.
You have three knobs:
-
Channel capacity. Bigger = more tolerance for slow clients = more memory. For
OrderEvent(~100 bytes), a capacity of 1024 is ~100KB per channel. For a small handful of channels, this is nothing. For per-tenant channels, do the math. -
Disconnect lagged subscribers. Treat
Laggedas fatal: end the stream, force the client to reconnect (and useLast-Event-IDto resume). This trades reconnect overhead for bounded memory.
let stream = BroadcastStream::new(rx).take_while(|res| {
let keep = matches!(res, Ok(_));
async move { keep } // stop the stream on the first Err
});
- Drop messages selectively. If your event is “current value of X” (a price tick, a progress percentage), only the latest matters. Use
tokio::sync::watchinstead ofbroadcast: every subscriber always sees the latest value and never gets a backlog.
The right choice depends on your data. Audit log events: tolerate lag, never drop. Stock prices: only latest matters, use watch. User notifications: kick lagged clients, force reconnect.
Graceful shutdown that drains streaming connections
Part 1 covered SIGTERM handling for normal request/response endpoints. SSE complicates it: a streaming connection might be “in flight” for hours. You don’t want shutdown to block until the last client disconnects.
The pattern: when shutdown starts, drop the broadcast sender, which causes every subscriber to receive RecvError::Closed. Each SSE stream then completes naturally, the HTTP response ends with a final chunk, the client sees EventSource.onerror and reconnects to the next pod that’s still alive (because your load balancer already removed this one when /ready flipped to failing).
async fn shutdown_signal(state: AppState) {
tokio::signal::ctrl_c().await.ok();
tracing::info!("shutdown signal — flipping readiness off");
// 1. Mark not-ready so the load balancer stops sending new connections.
state.ready.store(false, Ordering::SeqCst);
// 2. Give the load balancer ~15s to notice and drain.
tokio::time::sleep(Duration::from_secs(15)).await;
// 3. Drop the broadcast sender to end existing SSE streams gracefully.
// (In practice you'd take the sender out of an Option<Sender> inside
// a Mutex; left as an exercise.)
tracing::info!("draining streaming connections");
}
With this in place, a deploy looks like:
- New pod comes up, gets traffic.
- Old pod’s
/readystarts returning 503. - Load balancer stops routing to old pod.
- After ~15s, old pod’s streaming connections are closed cleanly.
- Clients reconnect, hit the new pod, resume from
Last-Event-ID. Zero data loss, zero visible interruption.
Metrics every streaming endpoint should expose
Three numbers are non-negotiable:
use metrics::{counter, gauge, histogram};
// in your handler:
gauge!("sse.connections.active").increment(1.0);
// on each event delivered:
counter!("sse.events.delivered_total", "endpoint" => "orders").increment(1);
// on lagged:
counter!("sse.subscribers.lagged_total").increment(1);
// on disconnect:
gauge!("sse.connections.active").decrement(1.0);
Plot connection count over time and you can see deploys, network blips, and slow rollouts. Plot delivered events / lagged events and you can see when subscribers are falling behind faster than they’re catching up: that’s your “scale up” signal.
When SSE isn’t the right call
To finish, the same honest reality check as Part 1:
- Need bidirectional in the same channel? Use WebSockets. Axum has first-class
axum::extract::wssupport: same router, same extractors, different response type. SSE is one-way only. - Need binary efficiency? SSE is text. For high-rate binary streams (audio, video, large protobuf), WebSockets (or gRPC streaming) are dramatically more efficient.
- Client is mobile, on flaky cellular? SSE technically works, but the constant reconnects burn battery. A long-poll or push-notification design might be kinder.
- You’d be sending one event every few minutes? A regular HTTP polling endpoint with a short cache TTL is simpler and just as good.
SSE is the right tool when you have a server-side event stream, many concurrent listeners, plain HTTP, and a browser client that can use EventSource. For that exact use case, which covers most “real-time dashboard” and “live feed” workloads, nothing in the Rails ecosystem comes close to what an Axum + Tokio + Postgres stack can do on modest hardware.
Where we’re going next
Part 4 will tackle sharing authentication with the monolith: how to verify Rails session cookies, validate JWTs, share a Devise session secret, or use cookie-encrypted signed cookies from a Rust service. This is the gatekeeping problem that most teams hit right after they ship their first read endpoint and their second one (“but how does Rust know who the user is?”).
Two endpoints in, you should now have a real microservice serving JSON reads (Part 1), respecting the monolith’s database invariants (Part 2), and pushing live updates to connected clients (Part 3). The hard part, the integration discipline, is mostly behind you. From here, every additional endpoint is faster than the last.