Stream events from a Node.js HTTP handler with the right headers, heartbeats and reconnects, contrast briefly with WebSockets, and run the same pattern as an Encore TypeScript raw endpoint next to a Postgres database.
Server-Sent Events let a Node.js service push a stream of updates to a browser over a single HTTP response. The format is simple: set Content-Type: text/event-stream, keep the response open, and write data: lines. The parts that trip people up are buffered writes, idle timeouts on load balancers, reconnects after a dropped connection, and timers that keep running after the user closed the tab.
This guide builds an SSE handler with Node's http module, covers heartbeats and Last-Event-ID, contrasts SSE with WebSockets, then shows the same pattern as an Encore TypeScript raw endpoint that streams job progress from Postgres.
The client sends a normal GET request, and the server answers with Content-Type: text/event-stream and keeps the response open, writing messages as lines of data:, with optional event: and id: lines, each message ending with a blank line. In the browser, EventSource parses the stream, dispatches one event per message and reconnects automatically if the connection drops, sending the last id it saw in a Last-Event-ID header. The format is defined in the HTML standard, and lines starting with a colon are comments, which is how servers send heartbeats.
The handler sets the headers, writes a message, and relies on res.write so each chunk leaves the process without waiting for res.end. Node's HTTP response is already a stream, so there is no Flusher type assertion the way Go needs one:
// server.mjs
import http from "node:http";
const server = http.createServer((req, res) => {
if (req.url !== "/events") {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
let id = 0;
const tick = setInterval(() => {
id += 1;
const payload = JSON.stringify({ at: new Date().toISOString() });
res.write(`id: ${id}\nevent: tick\ndata: ${payload}\n\n`);
}, 2000);
const heartbeat = setInterval(() => {
res.write(": ping\n\n");
}, 15000);
req.on("close", () => {
clearInterval(tick);
clearInterval(heartbeat);
});
});
server.listen(8080, () => {
console.log("SSE on http://localhost:8080/events");
});
Run it with node server.mjs and open the stream with curl -N localhost:8080/events, where -N disables curl's own buffering so each message prints as it arrives. The same headers and write loop work inside Express with (req, res) => { ... } as long as you call res.write rather than res.json or res.end until the client disconnects.
The request emits close when the client disconnects, so every SSE loop should clear its timers there, otherwise each closed tab leaves intervals behind. Proxies and load balancers close connections that look idle; the AWS Application Load Balancer, for example, has a default idle timeout of 60 seconds. The heartbeat comment every 15 seconds keeps the connection active without showing up as an event in the browser.
For reconnects, give each message an id and read req.headers["last-event-id"] at the start of the handler, so a reconnecting client resumes from the next message instead of starting over. An in-memory counter works for a single process; once the service runs on several instances behind a load balancer, the stream should read from a source every instance can see, such as a database row a worker updates.
EventSource opens the stream, parses each message and reconnects on its own. Named events (event: tick) need addEventListener, while messages without an event: line arrive on onmessage:
const source = new EventSource("/events");
source.addEventListener("tick", (e) => {
const data = JSON.parse(e.data);
console.log("tick", e.lastEventId, data.at);
});
source.onerror = () => {
// The browser retries automatically; close() stops it for good.
console.log("connection lost, readyState", source.readyState);
};
EventSource cannot set custom headers, so token auth usually rides on a cookie for the same origin, or on withCredentials: true for a cross-origin stream with CORS configured. The server can change the reconnect delay by sending a retry: line with a value in milliseconds. To stop a stream that has finished, for example when a job reaches 100 percent, have the server send a final event and call source.close() in its handler, since a plain response end makes the browser reconnect.
Express hands you the same Node.js response object, so the write loop is unchanged. Call res.flushHeaders() after setting the headers so the client sees the response start right away, and avoid compression middleware on this route, since it buffers output until it has enough bytes to compress:
import express from "express";
const app = express();
app.get("/events", (req, res) => {
res.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.flushHeaders();
const timer = setInterval(() => {
res.write(`data: ${Date.now()}\n\n`);
}, 2000);
req.on("close", () => clearInterval(timer));
});
app.listen(8080);
Behind nginx, set proxy_buffering off for the route, or send an X-Accel-Buffering: no response header, so the proxy forwards each message instead of holding it in its buffer.
Pick SSE when data flows one way from the server. Pick WebSockets when both sides talk continuously. The companion guide SSE vs WebSockets covers the six-connection limit on HTTP/1.1, auth limits on EventSource, and scaling across instances in more depth, and How to Implement Server-Sent Events in Go is the same pattern with net/http.
Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. For plain Server-Sent Events that an EventSource can consume, a raw endpoint gives you the Node.js request and response. Typed streaming APIs such as api.streamOut are a different path: they send typed JSON over a WebSocket under the hood, which fits generated clients and two-way chat, not the SSE wire format.
Run in your terminal to get started locally.
The migration creates the table a worker updates:
-- jobs/migrations/1_create_jobs.up.sql
CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
percent INT NOT NULL DEFAULT 0,
stage TEXT NOT NULL DEFAULT 'queued'
);
The raw endpoint looks up the job every second and sends an event whenever the progress changes. Path parameters come from currentRequest(), the same pattern the file upload guide uses for /files/:name:
// jobs/events.ts
import { api } from "encore.dev/api";
import { APICallMeta, currentRequest } from "encore.dev";
import { SQLDatabase } from "encore.dev/storage/sqldb";
const db = new SQLDatabase("jobs", { migrations: "./migrations" });
export const events = api.raw(
{ expose: true, method: "GET", path: "/jobs/:id/events" },
async (req, resp) => {
const { id } = (currentRequest() as APICallMeta).pathParams;
const initial = await db.queryRow<{ percent: number; stage: string }>`
SELECT percent, stage FROM jobs WHERE id = ${Number(id)}
`;
if (!initial) {
resp.writeHead(404);
resp.end("job not found");
return;
}
resp.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
let last = "";
const send = (row: { percent: number; stage: string }) => {
const payload = JSON.stringify(row);
if (payload !== last) {
resp.write(`event: progress\ndata: ${payload}\n\n`);
last = payload;
}
};
send(initial);
let tick: ReturnType<typeof setInterval> | undefined;
let heartbeat: ReturnType<typeof setInterval> | undefined;
const stop = () => {
if (tick) clearInterval(tick);
if (heartbeat) clearInterval(heartbeat);
};
tick = setInterval(async () => {
const row = await db.queryRow<{ percent: number; stage: string }>`
SELECT percent, stage FROM jobs WHERE id = ${Number(id)}
`;
if (!row) {
stop();
resp.end();
return;
}
send(row);
if (row.percent >= 100) {
stop();
resp.end();
}
}, 1000);
heartbeat = setInterval(() => {
resp.write(": ping\n\n");
}, 15000);
req.on("close", stop);
},
);
encore run starts Postgres locally and applies the migration. Insert a row with encore db shell jobs, then open curl -N localhost:4000/jobs/1/events while you update the row to watch events arrive. The local development dashboard shows a trace for the request with each database query. On deploy, Encore provisions the database as RDS on AWS or Cloud SQL on GCP, runs the service on Fargate, EKS, Cloud Run or GKE behind the load balancer it provisions, and provisions an IAM role for the service.
Best fit: TypeScript teams who want Server-Sent Events that read from the same Postgres, queues and secrets as the rest of the service, deployed to their own AWS or GCP account.
Create an HTTP handler that sets Content-Type to text/event-stream, Cache-Control to no-cache and Connection to keep-alive, then write messages as data: lines ending with a blank line and call res.write for each one. Stop writing when the request close event fires, which happens when the client disconnects.
Proxies and load balancers close idle connections. An AWS Application Load Balancer has a default idle timeout of 60 seconds, so send a comment line such as : ping every 15 seconds to keep the connection active without firing a browser event.
Use SSE when the server pushes updates and the client only listens, such as notifications, job progress or streamed AI output, since it works over plain HTTP and EventSource reconnects automatically. Use WebSockets when the client also sends a steady stream of messages.
Define an api.raw endpoint with method GET and a path such as /jobs/:id/events, set Content-Type to text/event-stream on the response, and write data: lines until the request closes. Read path parameters with currentRequest().pathParams. Typed api.streamOut endpoints exist too, but they run over a WebSocket rather than Server-Sent Events.
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.