Real-Time UI Updates with SSE: Simpler Than WebSockets

Ever built a web app where users click a button…and wait…and wait…while the server finishes some long-running job? Spinners are fine—but they leave users in the dark. Wouldn’t it be amazing if your UI could show live progress updates as things happen on the server, without the complexity of WebSockets? That’s exactly what Server-Sent Events (SSE) make possible.
In this post, I’ll walk you through:
- What SSE is and how it compares to WebSockets
- The real-life problem it solves
- A practical example: running a multi-step workflow where each node streams progress to a chatbot UI
- How to implement this in Next.js + React
- Technical deep dive into how streams work under the hood
- Pros, cons, and performance considerations
Let’s go!
What is Server-Sent Events (SSE)?
SSE is a web standard that allows your server to push updates to the browser over a single HTTP connection. No WebSocket handshake. No polling. No fuss.
- Uni-directional stream — server → browser
- Works over plain HTTP/2 or HTTP/1.1
- Super simple protocol
- Reconnects automatically if the connection drops
A Real-World Example: Streaming a Workflow Execution
Let’s imagine a real-life scenario: You’ve built an automation tool where users design workflows as graphs. Each node in the graph might:
- Make API calls
- Run computations
- Chain into other nodes
Some nodes can take several seconds to execute.
Let’s say you have a UI where users can build a workflow like this:
Orchestrator
↓
ComputeAgent
↓
StorageAgent
The Native Approach
A native implementation might:
Frontend → POST /api/run-workflow
→ run the entire workflow
→ backend does everything
→ Respond to the browser with the **final result only**
Frontend displays final result.
The problem:
- User waits forever.
- No idea which node is running.
- Can’t show progress.
- Can’t handle huge data streams easily.
When the user clicks “Run,” you don’t want them staring at a blank spinner for 30 seconds. Instead you want something like:
- Node Orchestrator started
- Node Orchestrator result: some data
- Node ComputeAgent started
- Node ComputeAgent result: some data
- Workflow complete
That’s where SSE saves the day.
How SSE Solves This
Instead of responding once at the end, the server streams progress messages as they happen.
Your browser listens for events and updates the UI instantly. Instead of one big JSON response, you stream chunks like this:
Example messages:
data: { "event": "node-started", "nodeId": "abc" }
data: { "event": "node-completed", "nodeId": "abc", "result": "..." }
data: { "event": "workflow-complete" }
That’s powerful because:
- The browser receives updates as soon as they’re available.
- You keep a single, long-lived HTTP connection.
- It’s simpler than WebSockets for many use cases.
How Does SSE Work?
- Browser makes a GET request with: Accept: text/event-stream
- Server keeps the connection open.
- Server writes text like: data: some data\n\n
- Each chunk triggers an
onmessageevent in JavaScript.
Why Not WebSockets?
You might ask:
Why not just use WebSockets?
Great question!
WebSockets are fantastic—but they’re:
- More complex (full bi-directional protocol)
- Often overkill if the client doesn’t need to send real-time data back to the server
- Less compatible with certain hosting platforms or proxies
SSE is perfect when:
- You want to push updates from server → client
- You want super simple setup
Practical Implementation in Next.js
Let’s dive into a working example.
I’m using:
- Next.js for the backend API route
- React for a chatbot-style frontend
The goal:
Run a workflow, node by node, and stream progress messages in real-time to the browser.
Backend SSE Route
Here’s a complete Next.js route:
export async function GET(req, { params }) {
const { id } = params;
// Load workflow from DB
const workflow = await getWorkflowById(id);
if (!workflow) {
return new Response(JSON.stringify({ error: "Workflow not found" }), {
status: 404,
});
}
let flow;
try {
flow = JSON.parse(workflow.data.flow);
} catch (e) {
return new Response(JSON.stringify({ error: "Invalid workflow data" }), {
status: 500,
});
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
const send = (data) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
);
};
const context = {};
const visited = new Set();
const { nodes, edges } = flow;
const targets = new Set(edges.map((e) => e.target));
const startNodes = nodes.filter((n) => !targets.has(n.id));
for (const start of startNodes) {
await runNodeRecursive(
start.id,
nodes,
edges,
visited,
send,
context,
);
}
send({ event: "workflow-complete" });
controller.enqueue(encoder.encode("\n"));
controller.close();
} catch (err) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ error: err.message })}\n\n`),
);
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
How It Works
- TextEncoder: Converts strings → bytes for streaming.
- ReadableStream: Keeps the HTTP connection open. You can call controller.enqueue(...) repeatedly.
- send(...) Formats each message as: data: some data\n\n
runNodeRecursive(...)
- Visits each node in the graph.
- Executes node logic.
- Emits events like:
{ event: "node-started", nodeId: "123" }
{ event: "node-completed", nodeId: "123", result: "..." }
- controller.close(): Ends the stream once the workflow completes.
Frontend: React Chatbot UI
On the client side, it’s beautifully simple.
Example:
const handleRunWorkflow = (userPrompt) => {
setMessages((prev) => [...prev, { type: "user", text: userPrompt }]);
setRunning(true);
const eventSource = new EventSource(
`/api/workflows/${workflowId}/run?prompt=${encodeURIComponent(userPrompt)}`,
);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.event === "node-started") {
setMessages((prev) => [
...prev,
{
type: "system",
text: `Node ${data.nodeId} (${data.type}) started`,
},
]);
} else if (data.event === "node-completed") {
setMessages((prev) => [
...prev,
{
type: "bot",
text: `Node ${data.nodeId} result:\n${data.result}`,
},
]);
} else if (data.event === "workflow-complete") {
setMessages((prev) => [
...prev,
{
type: "system",
text: "Workflow complete!",
},
]);
setRunning(false);
eventSource.close();
}
};
eventSource.onerror = (err) => {
console.error("SSE error", err);
setMessages((prev) => [
...prev,
{ type: "error", text: "SSE connection error" },
]);
setRunning(false);
eventSource.close();
};
};
What Happens
When the user submits a prompt:
- Opens an EventSource connection.
- Server sends:
data: { "event": "node-started", ... }
- Frontend displays:
Node Orchestrator started
Node Orchestrator result: ...
...
Workflow complete!
The Benefits
SSE makes your app:
- Real-Time Feedback → Users see progress, not a spinning wheel.
- Lightweight → Simple protocol, low overhead.
- Easy to integrate → No WebSocket servers required.
- Reliable → Automatic reconnect in most browsers.
Downsides of SSE
SSE is brilliant, but:
- It’s one-way only (server → client).
- Not all enterprise proxies handle streaming perfectly.
- Browser support excludes older Internet Explorer.
But for modern apps—it’s usually perfect.
The Result
This approach turned my boring “wait and spin” workflows into a real-time conversation. Users can see:
- which node runs
- live progress
- errors as they happen
All thanks to a few elegant lines of streaming code.
Conclusion
If you’re running:
- Long-running AI pipelines
- Workflow engines
- Background Jobs
- Any multi-step processes
…and you want users to see real progress, SSE might be the perfect solution.
Have you tried SSE in production?
Drop a comment and share your war stories or reach out on [email protected] — I’d love to help or hear your thoughts.
Social Links:
Youtube | GitHub | LinkedIn
Thank you, Muhib.