SSE in Practice
The 6-connection limit
Browsers limit the number of simultaneous HTTP/1.1 connections per domain to 6. Each SSE connection uses one of these slots. If your app opens 6 SSE connections (to different boards, different feeds), no other HTTP requests can be made to the same domain.
Solutions:
HTTP/2: Multiplexes many streams over one connection. The 6-connection limit does not apply. Most modern servers and browsers support HTTP/2.
Single connection with filtering: Open one SSE connection and subscribe to topics within it. The server filters events based on the client’s subscriptions.
// Single connection with topics
const source = new EventSource("/events?boards=board-1,board-2"); Different subdomains: api.example.com for REST, stream.example.com for SSE. Each subdomain has its own connection limit.
For most apps, HTTP/2 solves this automatically. If you are on HTTP/1.1, use a single SSE connection.
Heartbeats
Some proxies and load balancers close idle connections after 60-120 seconds. If no events are sent during that time, the connection is killed. The browser reconnects, but the user experiences a delay.
Send heartbeat events to keep the connection alive:
// src/sse.ts — add heartbeat
const HEARTBEAT_INTERVAL = 15_000; // Every 15 seconds
setInterval(() => {
for (const client of clients) {
try {
client.controller.enqueue(": heartbeat\n\n");
} catch {
const index = clients.indexOf(client);
if (index !== -1) clients.splice(index, 1);
}
}
}, HEARTBEAT_INTERVAL); The : heartbeat\n\n is an SSE comment (lines starting with : are ignored by the client). It keeps the connection open without triggering event handlers.
Connection counting
Track how many clients are connected for monitoring:
export function getConnectionCount(): number {
return clients.length;
}
export function getConnectionCountByBoard(boardId: string): number {
return clients.filter((c) => c.boardId === boardId).length;
}
// Expose as a monitoring endpoint
route.get("/admin/connections", {
resolve: () => {
return Response.json({
total: getConnectionCount(),
boards: Object.fromEntries(
[...new Set(clients.map((c) => c.boardId))].map((id) => [
id,
getConnectionCountByBoard(id),
]),
),
});
},
}); Load balancing
Each SSE connection is bound to a specific server. If you have 3 servers behind a load balancer, a client connects to server 1. Events broadcast on server 2 are not seen by that client.
Solutions (covered in detail in the scaling lesson):
- Sticky sessions: The load balancer routes the same client to the same server. Works but limits scalability.
- Pub/sub backbone: All servers subscribe to a shared message bus (Redis). Events broadcast on any server are forwarded to all servers.
For a single-server deployment (which covers most apps), this is not an issue.
When SSE is enough
SSE is the right choice when:
- The server pushes data and the client only listens (one-way)
- You want automatic reconnection without writing reconnection logic
- You need event types and resumption (Last-Event-ID)
- Your app already uses HTTP and you do not want to add a new protocol
SSE is not enough when:
- The client needs to send messages to the server frequently (use WebSockets)
- You need binary data (SSE is text only)
- You need sub-millisecond latency (WebSockets have slightly less overhead)
For a task board: SSE handles all the “push updates to viewers” use cases. WebSockets are needed for “typing indicators” and “real-time cursor position” — bidirectional features. The next section adds WebSockets.
Exercises
Exercise 1: Add the heartbeat. Connect with curl and observe the heartbeat comments arriving every 15 seconds.
Exercise 2: Add the connection count endpoint. Connect multiple clients and verify the count.
Exercise 3: Open 7 SSE connections from the same browser on HTTP/1.1. The 7th should stall (waiting for a connection slot). Close one, and the 7th connects.
Why do we send heartbeat comments instead of heartbeat events?