I’m currently building a school management system with separate applications for school administrators and parents.
An administrator can publish an announcement, and parents should know about it immediately. They should not have to refresh the page or wait for the application to check the server every few seconds.
This sounds like a small feature.
An announcement is created. A notification count goes up. Simple.
But how does a browser know that something new happened when it was the server, not the browser, that received the new data?
That was the actual problem.
The notification problem
A normal HTTP request starts from the client.
The browser asks a question, the server returns an answer, and the connection ends. This works perfectly when a user opens the announcements page because the browser already knows it needs the data.
It is different when the server needs to start the conversation.
The easy solution is polling. The browser can make a request every few seconds:
setInterval(async () => {
const announcements = await fetch("/api/announcements?audience=parents");
updateNotifications(announcements);
}, 5000);
This works, but most requests would return nothing new. Reducing the interval makes the notification feel faster, but also increases the number of useless requests. Increasing it saves requests, but the application no longer feels real-time.
What I needed was something similar to the experience Firebase offers: keep the application informed and push an update when a new event happens.
I had two separate communication problems to solve:
- How does an event travel between parts of the backend?
- How does that event travel from the backend to a browser?
Pub/Sub solved the first problem. Server-Sent Events solved the second.
Why Server-Sent Events?
WebSockets are usually the first thing that comes to mind when real-time systems are discussed. They provide two-way communication over a persistent connection.
For announcements, I did not need two-way communication.
The parent portal was not sending messages through the connection. It only needed to hear one thing from the server: a new announcement is available.
Server-Sent Events (SSE) fit this nicely. The browser opens a long-lived HTTP connection, and the server sends events through it as they happen.
It is still HTTP, the event format is straightforward, and
the browser already provides an EventSource
API with reconnection support.
The browser side can be as small as this:
const events = new EventSource(
"/events/announcements?audience=parents"
);
events.addEventListener("broadcast", () => {
setNotifications((count) => count + 1);
});
When the page unmounts, the connection is closed:
return () => {
events.close();
};
Now the browser can listen, but the server still needs to know that an administrator published something.
Enters Pub/Sub.
The first implementation: Kafka
The first version used Kafka.
An administrator sends a POST request containing
the title, message and intended group. The backend publishes
a small event to the group’s topic and stores the complete
announcement in PostgreSQL.
Roughly, the publishing side looks like this:
state
.pub_sub_producer
.send(topic, "New broadcast")
.await?;
create(&state.pool, request, school_slug).await?;
The payload is intentionally small. Kafka is not the announcement database here. PostgreSQL remains the source of truth for the title, message, audience and time sent.
The event only says: something changed.
This turns the message into an invalidation signal. The UI can update a counter immediately and fetch the stored announcements through the normal API when the user opens the page.
On the other side, the SSE route creates a consumer and subscribes it to the appropriate topic:
let mut consumer = AnyConsumer::consumer(
state.pub_sub_type.as_str(),
&state.pub_sub_url,
&state.pub_sub_consumer_session_timeout_ms,
&format!("sse-{}", uuid::Uuid::now_v7()),
)
.await?;
consumer.subscribe(&[topic]).await?;
It then waits for a message and converts each one into an SSE event:
let stream = stream! {
loop {
match consumer.recv().await {
Ok(Some(payload)) => {
yield Ok(
Event::default()
.event("broadcast")
.data(payload),
);
}
Ok(None) => break,
Err(error) => {
tracing::warn!("recv error: {:?}", error);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
};
Axum keeps the connection alive while the stream waits:
Sse::new(stream).keep_alive(KeepAlive::new().text("keep-alive"))
The complete flow became:
Admin UI
|
| POST /api/announcements
v
Rust API -----> PostgreSQL
|
| publish "New broadcast"
v
Kafka topic
|
| consume
v
SSE route -----> Parent browser
Bammm!
The parent dashboard can now change its notification count without polling the backend.
Keeping schools and audiences separate
The application later became multi-tenant. A
Parents topic by itself was no longer enough
because two schools could publish to the same audience.
The topic needed more context.
I scoped it using the school slug and broadcast group:
let topic = format!("{}:{}", school_slug, request.group.as_ref());
This produces channels such as:
school-one:Parents
school-one:Staff
school-two:Parents
A parent connected through one school’s domain only subscribes to that school’s channel. The group determines who should receive the announcement, while the school slug prevents one tenant’s events from leaking into another.
Making the broker replaceable
Kafka worked, but running a product is not only about what works technically.
There is also the cost of operating it.
For an early deployment on DigitalOcean, Redis was cheaper and simpler to start with. The problem was that the application already knew too much about Kafka. Routes created Kafka consumers directly, and services accepted Kafka producers directly.
Replacing it everywhere would tie a deployment decision to the business logic.
So I introduced a small common interface:
#[async_trait]
pub trait Producer: Send + Sync {
async fn send(&self, topic: &str, payload: &str) -> Result<(), ApiError>;
}
#[async_trait]
pub trait Consumer: Send + Sync {
async fn subscribe(&self, topics: &[&str]) -> Result<(), ApiError>;
async fn recv(&mut self) -> Result<Option<String>, ApiError>;
}
Both Kafka and Redis implement these traits. The rest of
the application works with AnyProducer and
AnyConsumer, which dispatch to the selected
implementation:
pub enum AnyProducer {
Kafka(KafkaProducer),
Redis(RedisProducer),
}
pub enum AnyConsumer {
Kafka(KafkaConsumer),
Redis(RedisConsumer),
}
The broker is selected through configuration:
let (producer, url) = match config.pub_sub_type.as_str() {
"kafka" => (
AnyProducer::Kafka(KafkaProducer::new(&config.kafka_url, timeout)?),
config.kafka_url.clone(),
),
"redis" => (
AnyProducer::Redis(RedisProducer::new(&config.redis_url)?),
config.redis_url.clone(),
),
_ => return Err(anyhow::anyhow!("Invalid pub/sub type").into()),
};
From the route’s point of view, nothing changes. It still subscribes, receives a string and sends an SSE event.
That was the important part.
Kafka and Redis do not have identical delivery guarantees. Kafka keeps a durable log that consumers can replay, while Redis Pub/Sub delivers messages to subscribers that are connected at that moment. But announcements are already stored in PostgreSQL, so losing an ephemeral notification does not lose the announcement itself.
For this use case, Redis Pub/Sub is allowed to be the bell. It does not have to be the filing cabinet.
Authentication and the browser
There was another small problem.
The native browser EventSource API does not let
you attach an arbitrary Authorization header.
The backend routes are authenticated, so connecting
directly from the browser would make token handling awkward.
The parent application already uses Next.js, so I added a same-origin proxy route.
The browser connects to a public-facing route such as:
/events/announcements?audience=parents
The proxy reads the authentication token from its HTTP-only
cookie, adds the bearer token to the request and forwards
the response body as a stream. It also forwards useful SSE
headers and Last-Event-ID when present.
This keeps the token out of the URL and lets
EventSource use a same-origin endpoint.
Reusing it for payments
Once this worked for announcements, another use case appeared.
A parent starts a payment and is redirected to the payment provider in a separate window. The provider eventually calls the backend webhook after the charge succeeds.
But the original parent page is still open.
How does it know the payment has finished?
The same architecture fits:
Payment webhook
|
| publish payment reference
v
payment-verified channel
|
| consume
v
SSE route -----> Parent browser
After validating the webhook, the backend publishes the payment reference:
pub_sub_producer
.send("payment-verified", payload.data.reference)
.await?;
The payment page listens for a named event and ignores messages belonging to another reference:
eventSource.addEventListener("payment-verified", async (event) => {
const receivedReference = event.data;
if (receivedReference !== reference) {
return;
}
await loadBill(true);
setShowSuccess(true);
eventSource.close();
});
The UI also retries a dropped SSE connection a limited number of times. When the matching event arrives, it reloads the bill, shows the success state and closes the payment flow.
No polling the payment provider. No asking the user to refresh the page.
The webhook is the event producer, Pub/Sub transports the signal, and SSE completes the final hop to the browser.
What I learnt
SSE and Pub/Sub solve different parts of the same problem.
Pub/Sub decouples the component where an event happens from the component waiting for it. SSE gives the server a simple one-way channel to the browser.
Together, they made the system feel real-time without making the notification itself the source of truth.
The progression also mattered. Kafka helped prove the event flow. Abstracting the producer and consumer later made Redis a configuration choice instead of a rewrite. PostgreSQL kept durable state, which meant the broker could be selected based on operational needs and cost.
Would I use SSE for every real-time feature?
No.
If the browser needed constant two-way communication, WebSockets would likely be a better fit. If every event had to be replayed reliably, I would lean more heavily on a durable stream. But for announcements and payment completion, server-to-browser signals backed by durable database records, SSE and Pub/Sub fit nicely.
Sometimes the browser does not need the complete event.
It only needs to know that something just happened. 😀