# WebSocket vs SSE vs Long Polling: Real-Time Updates Explained

> WebSocket vs SSE vs long polling: how each one delivers server updates, what it keeps open, Last-Event-ID reconnects, proxy buffering and idle timeouts.

URL: https://www.matthewswong.com/en/learn/system-design/websocket-vs-sse-vs-long-polling/

System design, step by step · Lesson 9 of 11 · Reviewed on 18 October 2026 · By Matthews Wong (https://www.matthewswong.com/en/about/)

> WebSockets, Server-Sent Events and long polling are ways for a server to send news to a browser without being asked over and over. Polling asks on a timer. Long polling holds each request open until there is news. SSE keeps one HTTP response open and streams events down it. A WebSocket upgrades the connection into a two-way channel.

## You will be able to

- Choose polling, long polling, SSE or a WebSocket for a feature by its direction, how often it changes, and what sits between client and server.
- Keep a stream alive on real networks: reconnection with Last-Event-ID, heartbeats under idle timeouts, and no buffering or compression on the way.
- Read this site's chatbot stream end to end, from the data: lines the route writes to the few lines that parse them.

## One new message, delivered four ways

A chat page waits for a new message through an nginx proxy: polling asks on a timer, long polling holds a request until the message arrives, SSE pushes it down an open response and resumes after a cut with Last-Event-ID, a WebSocket carries frames both ways, and this site's chatbot streams SSE over a POST.

1. **Meet the parts.** A chat page wants new messages from the app server, through an nginx proxy. A new message is about to arrive. Watch what each technique keeps open.
2. **Polling: ask on a timer.** Polling asks GET /messages every 5 seconds. Most answers come back empty, and a message that arrives just after an ask waits up to 5 seconds for the next one.

   ```
   setInterval(poll, 5000)
   ```

   *Predict:* Next, long polling: the server holds the request until there is news. When does the browser get the message? (At the next 5-second ask / As soon as it arrives / Only after a page reload)
3. **Long polling: hold the ask.** Long polling: the server holds the request open until news arrives, then answers with it. The browser asks again at once, so every message costs a full request and response.

   ```
   GET /messages?after=6
   ```
4. **SSE: one response, many events.** Server-Sent Events: one GET whose response stays open. The server answers text/event-stream and pushes each message down it as data lines and a blank line. Pushes go one way.

   ```
   id: 7
   data: message 7
   ```

   *Predict:* The chat goes quiet for 60 seconds, and nginx's proxy_read_timeout is 60s. What happens to the open stream? (nginx closes it / It stays open forever / The browser gives up for good)
5. **Cut, reconnect, resume.** After 60 quiet seconds nginx closes the stream. EventSource waits its retry time and reconnects with Last-Event-ID: 7; a server that reads it resumes after 7. Heartbeat comments prevent the cut.

   ```
   Last-Event-ID: 7
   ```
6. **WebSocket: both directions.** A WebSocket starts as a GET with Upgrade: websocket. The server answers 101 Switching Protocols, and one socket then carries frames both ways: typing goes up, the message comes down.

   ```
   Upgrade: websocket
   ```
7. **This site: SSE over POST.** This site's chatbot needs one question up and many tokens down, so it POSTs the question and streams the answer as SSE, read with fetch, because EventSource cannot send a POST.

   ```
   Content-Type: text/event-stream; charset=utf-8
   ```

## What are polling, long polling, SSE and WebSockets?

They are four ways for a browser to learn about news on the server. Polling asks on a timer: RFC 6202 describes a client sending regular requests that each try to pull any available data, so both the load and the delay depend on how often it asks. Long polling sends a request that the server holds open until an update is available; the server answers with it, and the client sends a new request straight away.

Server-Sent Events (SSE) turn one GET into a response that stays open. The server answers with the text/event-stream type and writes events down it as they happen, and the browser's EventSource API parses them. A WebSocket starts as an HTTP request carrying Upgrade: websocket; the server answers 101 Switching Protocols, and from then on, as RFC 6455 puts it, one TCP connection carries traffic in both directions.

## Which way does the data flow, and what does each one hold open?

Decide by direction first. MDN describes server-sent events as unidirectional, from the server to the client, unlike WebSockets; the client still talks back through ordinary requests. A WebSocket is the one built for traffic both ways, which pays off when the client sends often too, such as a chat that shows who is typing.

Then count what stays open. Polling holds nothing between asks, but every ask is a full HTTP request and most come back empty. Long polling holds one request per waiting client. RFC 6202 lists its costs: every message is a complete HTTP message with headers, and the worst-case delay is more than three network transits, because a message that lands just after an answer waits for the next request. SSE and WebSockets hold one long-lived connection per client and send each message without new HTTP headers.

Browsers add a limit of their own. When SSE is not used over HTTP/2, MDN warns that open EventSource connections are capped at 6 per browser and domain, shared by every tab. Over HTTP/2, the server and client negotiate how many streams they allow, 100 by default. A dashboard that opens one stream per widget, in a few tabs, runs into the cap on HTTP/1.1.

## How does a stream survive a dropped connection?

EventSource reconnects by itself; a WebSocket does not. When an event stream drops, the browser waits the reconnection time and opens it again. The HTML standard leaves the starting value to the browser, probably a few seconds, and the server can change it with a retry: line in milliseconds. The WebSocket standard has no reconnection step at all: a closed socket stays CLOSED, so a WebSocket client needs its own reconnect loop, with backoff and jitter.

Reconnecting is half the job; not losing events is the other half. If the server tags each event with an id: line, EventSource remembers the last one and sends it in a Last-Event-ID header on the next connection, so the server can resume right after it, as long as it kept that history. In the try-it below, the browser came back with Last-Event-ID: 3 and the stream carried on at event 4.

The server also decides when to stop. A 204 No Content answer tells EventSource to stop reconnecting, and so does any status other than 200 or a Content-Type other than text/event-stream. The standard calls that failing the connection, and after it the browser does not try again.

## Why does a stream that works locally stall behind a proxy?

Because every hop must pass each event on as it arrives, and stay patient while nothing does. HTTP does not require an intermediary to forward a partial response at once: RFC 6202 notes that a proxy may legally buffer the whole response, and that HTTP streaming does not work through one that does. The Next.js self-hosting guide says the same for nginx and shows the fix it recommends, an X-Accel-Buffering: no response header.

nginx buffers proxied responses by default (proxy_buffering on), and a response can switch that off for itself with X-Accel-Buffering: no. In a local test with nginx 1.31.7, the default settings still passed the events through one a second, as the server wrote them. What held them was compression: with gzip on for text/event-stream, curl received nothing in 45 seconds, and X-Accel-Buffering: no made nginx forward each event at once, still compressed. Check every layer, not only the proxy you expect.

Idle timeouts cut quiet connections. nginx's proxy_read_timeout, 60 seconds by default, closes the connection when the upstream sends nothing between two reads, and nginx's WebSocket guide gives the same 60 seconds and suggests ping frames. Cloudflare closes a WebSocket when no data moves in either direction for a while, may end WebSockets when it restarts servers for a release, and answers 524 when an origin sends no response within its 125-second proxy read timeout. The cure is a heartbeat: the HTML standard suggests a comment line, one starting with a colon, every 15 seconds or so.

WebSockets need one more thing from a proxy. Upgrade and Connection are hop-by-hop headers, so nginx does not pass them to the upstream unless told to: a WebSocket location needs proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade", plus proxy_http_version 1.1 on nginx older than 1.29.7. SSE needs none of that, because it is an ordinary response.

## Why does this site's chatbot stream SSE over a POST?

Because a chat answer is one question up and many small pieces down, the shape SSE fits. The route forwards the question to the model provider and, as tokens come back, writes each one as a data: line holding a small JSON object with a delta, then a done event, or an error event with a code. The reply is plain HTTP with Content-Type text/event-stream, so it needs no upgrade step at any proxy, and a request rejected before streaming, by the rate limit or validation, still gets an ordinary JSON error.

It cannot use EventSource. The EventSource constructor takes a URL and one option, withCredentials, and the standard's request steps never set a method or a body, so it can only send a GET with no body. The chatbot sends a message of up to 1000 characters plus recent conversation turns, which belong in a POST body. So it calls fetch with POST, takes response.body, a ReadableStream, and reads it chunk by chunk through getReader().

Reading the stream yourself means doing EventSource's parsing by hand, and lib/api/sse.ts shows the essentials in a few lines. A chunk boundary can fall anywhere, even inside an event, so the parser splits on the blank line and keeps the unfinished tail for the next chunk. The decoder runs with stream: true, which tells it more data will follow; in a local test, decoding the two bytes of é in separate calls without it gave two replacement characters, and with it gave é.

What it gives up is EventSource's reconnection and Last-Event-ID. That trade suits a chat answer: the route sends no id: lines and keeps no history, so there is nothing to resume from. If the server reports a failure mid-answer, the chatbot keeps the partial reply and marks it interrupted, and the visitor asks again.

## Why do WebSockets need shared state to scale?

Because a long-lived connection belongs to one server process, and the news may land on another. With two app servers behind a load balancer, a message posted through server A has to reach a client whose socket server B holds. The usual answer is a pub/sub channel every server subscribes to, such as Redis Pub/Sub: each server publishes what it receives and forwards what it hears to its own clients. Socket.IO calls the part that routes messages between servers the adapter.

Know what the channel promises. Redis documents Pub/Sub as at-most-once delivery: a subscriber that is disconnected when a message goes out never gets it, and Redis Streams exist for stronger guarantees. Long polling adds a routing problem: Socket.IO notes that its long-polling transport sends several HTTP requests over one session, which must all reach the same server, so it needs sticky sessions; a WebSocket-only setup does not.

SSE scales the same way when one client's event must reach other clients. This site's chatbot sidesteps the problem by having nothing to share: each stream answers one visitor's own question, and no event ever goes to a second visitor. The rate limiter beside it is another story: as the reliability lesson points out, its counts live in one container's memory and would need a shared store before a second copy could run.

## The real config behind this lesson

`app/api/chat/route.ts:379-389`

```typescript
        for await (const chunk of completion) {
          const delta = chunk.choices[0]?.delta?.content;
          if (delta) {
            controller.enqueue(
              encoder.encode(`data: ${JSON.stringify({ delta })}\n\n`),
            );
          }
        }
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`),
        );
```

The whole wire format of the chatbot is these lines. Each token from the model becomes one event: a data: line holding JSON, ended by the blank line that the double \n makes. After the last token comes one done event, and the catch block below sends an error event instead. There are no id: or event: lines, because nothing here resumes and every event has the same type.

`app/api/chat/route.ts:407-411`

```typescript
  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
```

text/event-stream is what makes this an event stream. no-cache keeps a cache from answering another request with this reply unless it revalidates first. no-transform tells intermediaries not to change the body, and the compression middleware bundled with Next.js 15.5.7 skips any response that carries it. nginx's gzip module does not check it, though: in the local test, a stream with no-transform and gzip on for event streams still delivered nothing. Notice what is missing: X-Accel-Buffering.

`lib/api/sse.ts:22-35`

```typescript
  const blocks = buffer.split("\n\n");
  const rest = blocks.pop() ?? "";
  const events: ChatStreamEvent[] = [];
  for (const block of blocks) {
    const line = block.split("\n").find((l) => l.startsWith("data:"));
    const json = line?.slice("data:".length).trim();
    if (!json) continue;
    try {
      events.push(JSON.parse(json) as ChatStreamEvent);
    } catch {
      // A malformed event carries nothing a reader can show; skip it.
    }
  }
  return { events, rest };
```

split on the blank line, then pop: the last piece is whatever came after the final blank line, an event still arriving, so it goes back as rest for the next chunk. Fed an event split across two chunks, it returned the first half as rest and the whole event on the next call. It is not a full SSE parser: it reads one data: line per event and splits on \n only, so a CRLF stream or a multi-line data field yields nothing. That is fine here, because the same repository writes both ends.

`app/components/developers/ApiSandbox.tsx:84-93`

```tsx
        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        let buffer = "";
        let answer = "";
        while (true) {
          const { value, done } = await reader.read();
          if (done) break;
          buffer += decoder.decode(value, { stream: true });
          const { events, rest } = takeChatStreamEvents(buffer);
          buffer = rest;
```

The API sandbox on /developers reads the POST's reply this way: response.body is a ReadableStream, and each read() resolves when the next chunk has arrived, so the answer grows on screen while the model is still writing. stream: true keeps a character split across two chunks intact, and whatever the parser could not finish stays in buffer. The chat widget does the same with its own inline copy of the parser.

## What breaks

- **A layer that holds every event until the end.** Events stream on localhost and arrive in one lump, or never, in production. Some hop is buffering or compressing the response. In the local test, nginx with gzip on for text/event-stream sent curl nothing in 45 seconds. Send X-Accel-Buffering: no, keep text/event-stream out of gzip_types, and test through every hop, including any CDN.
- **An idle timeout cuts a quiet stream.** A stream with nothing to say gets closed by the first hop whose timer runs out. With proxy_read_timeout set to 3s in a local test, curl got the first comment and was cut after 3.0 seconds, and nginx logged that the upstream timed out while reading upstream. A comment line every 2 seconds kept the same stream open. Send a heartbeat shorter than the shortest idle timeout on the path.
- **EventSource cannot POST.** Its only option is withCredentials: no method, no body, no custom headers. A stream that needs a large request, or an Authorization header, cannot start from EventSource. Either POST the work first and open a GET stream for its id, or do what this site does: fetch with POST and parse the event stream from response.body.
- **Headers that never leave the server.** In a local test, a Node server that called writeHead and then waited 5 seconds for its first event sent nothing at all, and nginx answered 504 after its 3-second timeout, logged as timed out while reading response header. Write something straight away, a retry line or a comment, as the try-it server does.
- **A WebSocket that stays closed after a restart.** A deploy that restarts the server, or a CDN restarting its own servers, closes every socket, and the browser API will not reopen one. Reconnect in your own code with jittered backoff, so every client does not return in the same second, and fetch what was missed, because the socket carries no Last-Event-ID.
- **What I'd change here: one parser, one header.** The chat widget parses the stream with its own inline copy, which only accepts data: followed by a space, while the sandbox uses lib/api/sse.ts. One shared parser would keep them in step. The route also sends no X-Accel-Buffering: no; whether the live proxy buffers is a server setting outside this repository, and the header would make the route stream behind any nginx.

### Polling, long polling, SSE or WebSocket: what each costs and where it fits

|  | Polling | Long polling | SSE | WebSocket |
| --- | --- | --- | --- | --- |
| Direction | Client asks on a timer | Client asks, server answers when ready | Server to client | Both ways |
| Held open | Nothing between asks | One request per waiting client | One response per client | One socket per client |
| Delay for news | Up to one interval | Short, but a new request after every message | As soon as it is written | As soon as it is sent |
| Reconnection | Every ask is new | The next request | Built in, with Last-Event-ID | Your own code |
| Through proxies | Plain requests | Plain requests; long waits meet timeouts | Plain response; needs no buffering and a heartbeat | Upgrade headers passed, pings for idle timers |
| Fits | Rare changes, simple status checks | A fallback when nothing else gets through | Feeds, notifications, streamed AI answers | Chat with typing, games, live collaboration |

## Try it yourself

You need Node 22 or later, curl and a browser; the optional proxy steps also need Docker. Everything listens on 127.0.0.1, and nothing here sends a request to this site.

1. Save this server as sse.mjs and start it with node sse.mjs in its own terminal. It sends one event a second, starts after any Last-Event-ID it receives, and serves a page that listens with EventSource.

   ```bash
   // sse.mjs: one event a second, resumes after Last-Event-ID, and a page that listens
   import http from "node:http";
   
   const page = `<!doctype html>
   <title>SSE demo</title>
   <pre id="log">Open DevTools, then Network, then the events request.\n</pre>
   <script>
     const log = document.getElementById("log");
     const source = new EventSource("/events");
     source.onmessage = (event) => (log.textContent += event.lastEventId + ": " + event.data + "\\n");
     source.onerror = () => (log.textContent += "lost, readyState " + source.readyState + "\\n");
   </script>`;
   
   http
     .createServer((req, res) => {
       if (req.url !== "/events") return res.writeHead(200, { "Content-Type": "text/html" }).end(page);
       const lastId = req.headers["last-event-id"];
       console.log("connected, Last-Event-ID:", lastId ?? "none");
       let id = Number(lastId ?? 0);
       res.writeHead(200, {
         "Content-Type": "text/event-stream",
         "Cache-Control": "no-cache",
         // "X-Accel-Buffering": "no",
       });
       res.write("retry: 2000\n\n");
       const tick = setInterval(() => res.write(`id: ${++id}\ndata: message ${id}\n\n`), 1000);
       req.on("close", () => clearInterval(tick));
     })
     .listen(8080, process.env.HOST ?? "127.0.0.1", () => console.log("listening on :8080"));
   ```
2. Watch the raw stream. -N turns off curl's own output buffering, so each event prints as it arrives: a retry line, then an id and a data line every second, each followed by a blank line. Stop it with Ctrl+C.

   ```bash
   curl -N http://127.0.0.1:8080/events
   ```
3. Resume the way EventSource does after a drop. The server logs the header it received, and the stream starts at 42.

   ```bash
   curl -N -H "Last-Event-ID: 41" http://127.0.0.1:8080/events
   ```
4. Open http://127.0.0.1:8080 in Chrome, then DevTools, Network, select the events request and open its EventStream tab: the events appear as they arrive. In Firefox, the same request's Response tab lists them.
5. With the page open, stop the server with Ctrl+C and start it again. The page logs lost, readyState 0, reconnects after the 2-second retry, and the server prints the Last-Event-ID the browser sent; the numbers carry on where they stopped.

   ```bash
   node sse.mjs
   ```
6. Optional, with Docker: save this as nginx.conf next to sse.mjs. It turns on gzip for event streams, a line that looks harmless.

   ```bash
   server {
     listen 80;
     gzip on;
     gzip_types text/event-stream;
     location / {
       proxy_pass http://sse-app:8080;
     }
   }
   ```
7. Run the server and nginx in containers on one network, then ask through nginx the way a browser does, accepting gzip. curl prints no events at all, and --max-time ends it after 15 seconds.

   ```bash
   docker network create sse-demo
   docker run -d --name sse-app --network sse-demo -e HOST=0.0.0.0 -v "$PWD:/demo:ro" node:22-alpine node /demo/sse.mjs
   docker run -d --name sse-proxy --network sse-demo -p 127.0.0.1:8081:80 -v "$PWD/nginx.conf:/etc/nginx/conf.d/default.conf:ro" nginx:alpine
   curl -N --compressed --max-time 15 http://127.0.0.1:8081/events
   ```
8. In sse.mjs, delete the two slashes in front of X-Accel-Buffering, restart both containers and ask again: one event a second, still compressed, because nginx now passes the response on as it receives it.

   ```bash
   docker restart sse-app sse-proxy
   sleep 2
   curl -N --compressed --max-time 5 http://127.0.0.1:8081/events
   ```
9. Clean up the containers and the network, and stop node sse.mjs with Ctrl+C.

   ```bash
   docker rm -f sse-app sse-proxy
   docker network rm sse-demo
   ```

## Quick quiz

1. **A dashboard shows live order counts. The server pushes updates; the browser sends nothing back except page loads. Which fits with the fewest moving parts?**
   A. A WebSocket
   B. Server-Sent Events
   C. Polling every second
   D. Long polling
   Answer: B. The data flows one way, from server to browser, which is exactly what SSE carries. It is a plain HTTP response, needs no upgrade at the proxy, and EventSource reconnects by itself. A WebSocket would add a two-way channel nobody uses.

2. **Events stream fine on localhost, but behind nginx in production they arrive all at once at the end. What do you check first?**
   A. Raise proxy_read_timeout
   B. Switch to a WebSocket
   C. Buffering or gzip on the way, and X-Accel-Buffering: no
   D. Add an id: line to each event
   Answer: C. Everything arriving together at the end means a hop held the response, by buffering or compressing it. A timeout would cut the stream, not delay it, and ids only matter for resuming after a drop.

3. **An EventSource reconnects after its connection dropped. How does the server know where to resume?**
   A. The browser sends the last id it saw in a Last-Event-ID header
   B. The browser replays every event it received
   C. The proxy remembers the position
   D. It cannot; SSE always restarts from the beginning
   Answer: A. EventSource keeps the value of the last id: line and sends it as Last-Event-ID when it reconnects. The server still has to keep enough history to resume from it.

## Think it through

- **Why can't this site's chatbot use EventSource?** EventSource only sends a GET with no body, and its one option is withCredentials. The chatbot needs to POST a message and recent conversation turns, so it uses fetch with POST and parses the event stream from response.body itself.
- **A WebSocket chat works on one server and loses messages on two. Why?** Each socket lives on one server, so a message received by server A never reaches clients connected to server B. Every server needs to share messages through something like Redis Pub/Sub, and long polling also needs sticky sessions.
- **What does a line starting with a colon do in an event stream, and why send one?** It is a comment, and EventSource ignores it. Sent every 15 seconds or so, as the HTML standard suggests, it keeps bytes moving, so a proxy whose idle timeout is longer than that gap never closes the stream for being quiet.

## Go deeper

- [Server-Sent Events vs WebSockets for Realtime](https://www.matthewswong.com/en/blog/server-sent-events-vs-websockets-realtime/)
- [Server-Sent Events in Next.js: Simpler Than WebSockets](https://www.matthewswong.com/en/blog/server-sent-events-nextjs-streaming/)
- [Scaling WebSockets with Redis Pub/Sub: From One Server to Many](https://www.matthewswong.com/en/blog/websocket-scaling-redis-pubsub/)
- [WebSockets in NestJS: Building Real-Time Features in Production](https://www.matthewswong.com/en/blog/websockets-nestjs-realtime/)

## Sources

- [HTML Living Standard: Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html)
- [RFC 6455: The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455.html)
- [WHATWG WebSockets Standard (the browser API)](https://websockets.spec.whatwg.org/)
- [RFC 6202: Long polling and streaming in bidirectional HTTP](https://www.rfc-editor.org/rfc/rfc6202.html)
- [MDN: EventSource (connection limit)](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
- [MDN: EventSource() constructor](https://developer.mozilla.org/en-US/docs/Web/API/EventSource/EventSource)
- [MDN: Using server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
- [MDN: The WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)
- [MDN: Response.body (a ReadableStream)](https://developer.mozilla.org/en-US/docs/Web/API/Response/body)
- [MDN: Using readable streams](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams)
- [MDN: TextDecoder.decode() and the stream option](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/decode)
- [Fetch Standard: a request's default method and body](https://fetch.spec.whatwg.org/)
- [nginx docs: proxy_buffering, proxy_read_timeout, proxy_http_version](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
- [nginx docs: WebSocket proxying](https://nginx.org/en/docs/http/websocket.html)
- [nginx docs: the gzip module (gzip_types)](https://nginx.org/en/docs/http/ngx_http_gzip_module.html)
- [Cloudflare docs: WebSockets](https://developers.cloudflare.com/network/websockets/)
- [Cloudflare docs: Connection limits (proxy read timeout)](https://developers.cloudflare.com/fundamentals/reference/connection-limits/)
- [Next.js docs: Self-hosting, streaming and X-Accel-Buffering](https://nextjs.org/docs/app/guides/self-hosting)
- [RFC 9111: HTTP Caching (no-cache, no-transform)](https://www.rfc-editor.org/rfc/rfc9111.html)
- [Redis docs: Pub/Sub delivery semantics](https://redis.io/docs/latest/develop/pubsub/)
- [Socket.IO docs: Using multiple nodes](https://socket.io/docs/v4/using-multiple-nodes/)
- [Chrome DevTools: Network reference (EventStream tab)](https://developer.chrome.com/docs/devtools/network/reference)
- [Firefox DevTools: Inspecting server-sent events](https://firefox-source-docs.mozilla.org/devtools-user/network_monitor/inspecting_server-sent_events/index.html)

---

- HTML version: https://www.matthewswong.com/en/learn/system-design/websocket-vs-sse-vs-long-polling/
- Bahasa Indonesia: https://www.matthewswong.com/id/learn/system-design/websocket-vs-sse-vs-long-polling/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
