WebSockets, Server-Sent Events, and HTTP Long Polling

WebSockets, Server-Sent Events, and HTTP Long Polling

~ 4 min read

Introduction

Modern web applications increasingly rely on fresh data. Chat messages, notifications, live dashboards, collaborative editing, and streaming updates all require the server to push information to the browser as soon as it’s available.

The problem is that HTTP, at its core, is request–response based. The browser asks, the server answers, and then the connection ends.

To work around this limitation, three common techniques are used:

  • HTTP long polling
  • Server-Sent Events (SSE)
  • WebSockets

Each has different performance characteristics and operational trade-offs.


HTTP Long Polling

How it works

  1. The client sends an HTTP request.
  2. The server keeps the request open until data is available (or a timeout occurs).
  3. The server responds.
  4. The client immediately opens a new request.

Node.js example (Express)

Server

// server-long-poll.js
import express from "express";

const app = express();
app.use(express.json());

const pendingResponses = new Set();

app.get("/poll", (req, res) => {
    res.setHeader("Content-Type", "application/json; charset=utf-8");
    res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
    res.setHeader("Connection", "keep-alive");

    pendingResponses.add(res);

    const timeout = setTimeout(() => {
        pendingResponses.delete(res);
        res.status(204).end(); // no data; client should immediately poll again
    }, 25_000);

    req.on("close", () => {
        clearTimeout(timeout);
        pendingResponses.delete(res);
    });
});

// Simulate "new data arrived"
app.post("/publish", (req, res) => {
    const payload = { message: req.body?.message ?? "update", ts: Date.now() };

    for (const r of pendingResponses) r.json(payload);
    pendingResponses.clear();

    res.json({ delivered: true, payload });
});

app.listen(3000, () => console.log("Long polling server on http://localhost:3000"));

Browser client


<script>
    async function poll() {
        try {
            const res = await fetch("/poll", { cache: "no-store" });
            if (res.status === 204) return poll(); // timeout/no data -> immediately retry
            const data = await res.json();
            console.log("Update:", data);
        } catch (err) {
            console.warn("Polling failed, retrying...", err);
            await new Promise(r => setTimeout(r, 1000));
        }
        poll();
    }

    poll();
</script>

Pros

  • Works everywhere
  • Easy to add to existing REST-ish backends

Cons

  • Inefficient at scale (lots of open requests)
  • Extra overhead (repeated HTTP headers and request churn)
  • Latency depends on timing and server behaviour

When to use long polling

  • Legacy constraints
  • Low-frequency updates
  • You want the simplest “push-like” approach without new infrastructure

Server-Sent Events (SSE)

How it works

SSE keeps a single HTTP connection open and streams text events from the server to the browser. Communication is strictly server → client.

Browsers expose this via EventSource, which also includes automatic reconnection.

Node.js example (Express)

Server

// server-sse.js
import express from "express";

const app = express();
app.use(express.json());

const clients = new Set();

app.get("/events", (req, res) => {
    // Required SSE headers
    res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
    res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
    res.setHeader("Connection", "keep-alive");

    // "Open" the stream (comment line)
    res.write(": connected\n\n");

    clients.add(res);

    // Optional heartbeat to keep some proxies happy
    const heartbeat = setInterval(() => {
        res.write(": ping\n\n");
    }, 15_000);

    req.on("close", () => {
        clearInterval(heartbeat);
        clients.delete(res);
    });
});

app.post("/publish", (req, res) => {
    const data = JSON.stringify({
        message: req.body?.message ?? "update",
        ts: Date.now(),
    });

    for (const client of clients) {
        client.write("event: message\n");
        client.write(`data: ${data}\n\n`);
    }

    res.json({ sent: clients.size });
});

app.listen(3000, () => console.log("SSE server on http://localhost:3000"));

Browser client


<script>
    const source = new EventSource("/events");

    source.addEventListener("message", (event) => {
        console.log("SSE update:", JSON.parse(event.data));
    });

    source.onerror = (err) => {
        console.warn("SSE error (browser will auto-reconnect):", err);
    };
</script>

Pros

  • Basic client API
  • Auto-reconnect built in
  • Efficient for one-way streams
  • Runs over standard HTTP (easy to proxy/debug)

Cons

  • One-way only (server → client)
  • Text-only (no binary)

When to use SSE

  • Live dashboards, feeds, notifications
  • Streaming logs/metrics
  • Anywhere you don’t need the client to push real-time messages back

WebSockets

How it works

WebSockets upgrade an HTTP connection into a persistent, full-duplex channel. Both client and server can send messages at any time, with low overhead and low latency.

Node.js example (ws)

Install:

npm i ws

Server

// server-ws.js
import http from "http";
import { WebSocketServer } from "ws";

const server = http.createServer();
const wss = new WebSocketServer({ server });

wss.on("connection", (ws) => {
    ws.send(JSON.stringify({ type: "welcome", ts: Date.now() }));

    ws.on("message", (raw) => {
        const text = raw.toString();

        // Echo example
        ws.send(JSON.stringify({ type: "echo", message: text, ts: Date.now() }));

        // Broadcast example (optional)
        // for (const client of wss.clients) {
        //   if (client.readyState === 1) client.send(text);
        // }
    });
});

server.listen(3000, () => console.log("WebSocket server on ws://localhost:3000"));

Browser client


<script>
    const ws = new WebSocket("ws://localhost:3000");

    ws.onopen = () => {
        ws.send(JSON.stringify({ type: "chat", message: "Hello server" }));
    };

    ws.onmessage = (event) => {
        console.log("WS message:", JSON.parse(event.data));
    };

    ws.onclose = () => {
        console.warn("WebSocket closed (add reconnect logic if needed)");
    };
</script>

Pros

  • True two-way real-time messaging
  • Very low latency
  • Supports binary data
  • Efficient for high-frequency updates

Cons

  • More operational complexity (proxies/LBs/timeouts)
  • You likely need to implement a reconnection strategy yourself
  • Overkill for simple one-way updates

When to use WebSockets

  • Chat, collaborative editing, multiplayer games
  • Presence/typing indicators
  • High-frequency interactive apps

Choosing the Right Tool

FeatureLong PollingSSEWebSockets
DirectionOne-wayOne-wayTwo-way
LatencyMediumLowVery low
ComplexityLowLowHigher
Binary dataNoNoYes
Best forLegacy/low update rateFeeds/dashboardsInteractive apps

Final thoughts

Real-time web communication isn’t about the most powerful tool, it’s about the simplest tool that fits your needs.

  • Start with SSE if you can.
  • Use WebSockets when you must.
  • Fall back to long polling when constraints demand it.

all posts →