How Server-Sent Events and WebSockets differ on direction, reconnects, proxies and scaling, with working TypeScript for both and what changes once the app runs behind a load balancer in AWS or GCP.
Server-Sent Events and WebSockets both keep a connection open so the server can push data without the client polling. SSE is a plain HTTP response that never ends, read in the browser with EventSource, while a WebSocket upgrades the connection into a two-way channel defined in RFC 6455.
The choice usually looks easy in a local demo and gets harder once the app runs behind a load balancer, opens in several tabs, or needs to survive a deploy. This guide compares the two on the details that decide it, with TypeScript for each, starting with how Encore handles both.
Each option is covered below, starting with Encore.
Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions and deploys into your own AWS or GCP account. For realtime, the TypeScript SDK has streaming APIs: api.streamIn, api.streamOut and api.streamInOut, each with typed handshake and message types, running over a WebSocket under the hood. Server-to-client progress updates look like this:
// jobs/progress.ts
import { api } from "encore.dev/api";
interface Handshake {
jobId: string; // passed as a query parameter in the handshake request
}
interface Progress {
percent: number;
stage: string;
}
export const progress = api.streamOut<Handshake, Progress>(
{ expose: true, auth: true, path: "/jobs/progress" },
async ({ jobId }, stream) => {
for (const [percent, stage] of [
[10, "queued"],
[60, "processing"],
[100, "done"],
] as const) {
await stream.send({ percent, stage });
await new Promise((r) => setTimeout(r, 1000));
}
await stream.close();
},
);
Run in your terminal to get started locally.
With auth: true, the stream goes through the same auth handler as your REST endpoints, with credentials passed in the handshake. The generated TypeScript client exposes the stream as an async iterator, so the frontend reads typed Progress objects instead of parsing strings. api.streamInOut gives you the two-way version for chat, and streams can also be called between services through ~encore/clients with expose: false to keep them internal.
When you want standard Server-Sent Events, for example to feed an EventSource in the browser or stream tokens from an LLM, a raw endpoint gives you the Node.js request and response:
// notify/events.ts
import { api } from "encore.dev/api";
export const events = api.raw(
{ expose: true, method: "GET", path: "/events" },
async (req, resp) => {
resp.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
let id = 0;
const tick = setInterval(() => {
id++;
resp.write(`id: ${id}\nevent: tick\ndata: ${JSON.stringify({ at: Date.now() })}\n\n`);
}, 5000);
// Stop writing when the browser closes the tab or EventSource.close() is called.
req.on("close", () => clearInterval(tick));
},
);
Both endpoints run with encore run, next to the rest of the app's databases and Pub/Sub topics, so you test the stream against the same services it reads from. A coding agent working in the repository can add a stream as one more endpoint and check it locally in the same session, without touching proxy or load balancer configuration. On deploy, Encore runs the services on Fargate, EKS, Cloud Run or GKE in your own account behind the load balancer it provisions, and the events the stream reports on can come from a Pub/Sub topic or database declared in the same codebase.
SSE is part of the HTML standard: the server answers a GET request with Content-Type: text/event-stream and keeps writing blocks of data:, event: and id: lines separated by a blank line. In the browser, EventSource parses the stream, fires an event per message, and reconnects automatically after a dropped connection, sending the last received id in a Last-Event-ID header so the server can resume.
That makes SSE a good fit for notifications, job progress, live metrics and streamed AI responses, where the client sends a request once and then listens. It runs over ordinary HTTP and works with existing cookies and most proxies, but that simplicity also sets its limits: messages are UTF-8 text only, EventSource cannot set custom request headers, so bearer tokens need a cookie or query parameter, and MDN notes that over HTTP/1.1 browsers allow only six open connections per domain across all tabs, a limit that HTTP/2 removes by multiplexing streams over one connection.
A WebSocket starts as an HTTP request with an Upgrade: websocket header. Once the server agrees, the connection switches protocols and both sides can send text or binary frames at any time, with ping and pong control frames for keepalive. In Node.js, the ws library is the common server implementation, and browsers have the built-in WebSocket API.
WebSockets fit chat, collaborative editing, multiplayer games and anything where the client sends a steady flow of messages. In exchange you own more of the plumbing: reconnect with backoff, resending missed messages, heartbeats, and authentication during the handshake, since the browser WebSocket constructor cannot set an Authorization header either.
Both kinds of connection are long-lived, and in the cloud an AWS Application Load Balancer closes connections with no traffic after its idle timeout, 60 seconds by default, so SSE servers should send a comment line or event and WebSocket servers a ping well inside that window. Deploys and scale-in also close open connections, so clients need to reconnect and catch up. For SSE, EventSource and Last-Event-ID handle much of that; for WebSockets it is application code. With several instances, each one only knows its own connections, so a broadcast has to go through a shared channel such as Redis pub/sub, or clients have to read from a shared store when they reconnect.
Pick SSE when data flows from the server to the client and the client only occasionally sends ordinary HTTP requests, which covers most notification, progress and AI streaming features. Pick WebSockets when both sides talk continuously or you need binary frames.
Pick Encore when you want either of them as typed endpoints in a TypeScript backend, tested locally with encore run alongside your databases and queues, and deployed to your own AWS or GCP account.
Server-Sent Events send a one-way stream of text events from the server to the browser over a normal HTTP response, and the browser's EventSource reconnects on its own. WebSockets upgrade the connection to a two-way channel where both sides can send text or binary messages at any time, and reconnect logic is up to you.
Use SSE when only the server pushes data, for example notifications, progress updates, live dashboards or streamed AI responses. It works over plain HTTP, passes through most proxies, and reconnects automatically. Use WebSockets when the client also sends frequent messages, as in chat, multiplayer editing or games.
Use api.streamOut for a typed server-to-client stream or api.streamInOut for two-way messaging; both run over a WebSocket. For plain Server-Sent Events, define an api.raw endpoint, set the Content-Type header to text/event-stream and write data: lines to the response until the client disconnects.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.