# Belajar WebSocket dan SSE untuk Pemula, Termasuk Long Polling

> Belajar WebSocket dan SSE untuk pemula: beda polling, long polling, SSE, dan WebSocket, reconnect dengan Last-Event-ID, buffering proxy, dan idle timeout.

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

System design langkah demi langkah · Pelajaran 9 dari 11 · Ditinjau pada 18 Oktober 2026 · Oleh Matthews Wong (https://www.matthewswong.com/id/about/)

> WebSocket, Server-Sent Events, dan long polling adalah cara server mengirim kabar baru ke browser tanpa harus ditanya berulang-ulang. Polling bertanya sesuai jadwal. Long polling menahan setiap request sampai ada kabar. SSE menjaga satu respons HTTP tetap terbuka dan mengalirkan event lewat respons itu. WebSocket meng-upgrade koneksi menjadi saluran dua arah.

## Setelah pelajaran ini, Anda bisa

- Memilih polling, long polling, SSE, atau WebSocket untuk sebuah fitur berdasarkan arah datanya, seberapa sering berubah, dan apa saja yang ada di antara klien dan server.
- Menjaga stream tetap hidup di jaringan sungguhan: reconnect dengan Last-Event-ID, heartbeat di bawah idle timeout, dan tanpa buffering atau kompresi di tengah jalan.
- Membaca stream chatbot situs ini dari ujung ke ujung, mulai dari baris data: yang ditulis route sampai beberapa baris kode yang mem-parse-nya.

## Satu pesan baru, dikirim dengan empat cara

Halaman chat menunggu pesan baru lewat proxy nginx: polling bertanya sesuai jadwal, long polling menahan request sampai pesan tiba, SSE mendorongnya lewat respons yang tetap terbuka dan melanjutkan setelah terputus dengan Last-Event-ID, WebSocket membawa frame ke dua arah, dan chatbot situs ini mengalirkan SSE lewat POST.

1. **Kenali bagiannya.** Halaman chat ingin menerima pesan baru dari app server, lewat proxy nginx. Sebuah pesan baru akan segera tiba. Perhatikan apa yang ditahan terbuka oleh tiap teknik.
2. **Polling: tanya berkala.** Polling mengirim GET /messages setiap 5 detik. Sebagian besar jawabannya kosong, dan pesan yang tiba tepat setelah sebuah request harus menunggu sampai 5 detik untuk request berikutnya.

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

   *Tebak:* Berikutnya long polling: server menahan request sampai ada kabar. Kapan browser menerima pesannya? (Saat request 5 detik berikutnya / Begitu pesannya tiba / Hanya setelah halaman di-reload)
3. **Long polling: tahan requestnya.** Long polling: server menahan request sampai ada kabar, lalu menjawab dengan kabar itu. Browser langsung bertanya lagi, jadi setiap pesan memakan satu request dan respons utuh.

   ```
   GET /messages?after=6
   ```
4. **SSE: satu respons, banyak event.** Server-Sent Events: satu GET yang responsnya tetap terbuka. Server menjawab text/event-stream dan mendorong setiap pesan lewat respons itu sebagai baris data dan satu baris kosong. Arahnya satu.

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

   *Tebak:* Chat sepi selama 60 detik, dan proxy_read_timeout nginx bernilai 60s. Apa yang terjadi pada stream yang terbuka? (nginx menutupnya / Tetap terbuka selamanya / Browser menyerah untuk selamanya)
5. **Putus, sambung, lanjut.** Setelah 60 detik sepi, nginx menutup stream. EventSource menunggu waktu retry lalu tersambung lagi dengan Last-Event-ID: 7; server yang membacanya melanjutkan setelah 7. Komentar heartbeat mencegah putusnya.

   ```
   Last-Event-ID: 7
   ```
6. **WebSocket: dua arah.** WebSocket dimulai sebagai GET dengan Upgrade: websocket. Server menjawab 101 Switching Protocols, lalu satu socket membawa frame ke dua arah: status mengetik naik, pesannya turun.

   ```
   Upgrade: websocket
   ```
7. **Situs ini: SSE lewat POST.** Chatbot situs ini butuh satu pertanyaan naik dan banyak token turun, jadi ia mengirim pertanyaan lewat POST dan mengalirkan jawabannya sebagai SSE yang dibaca dengan fetch, karena EventSource tidak bisa POST.

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

## Apa itu polling, long polling, SSE, dan WebSocket?

Keempatnya adalah cara browser mengetahui kabar baru di server. Polling bertanya sesuai jadwal: RFC 6202 menggambarkan klien yang mengirim request secara rutin dan setiap request mencoba menarik data yang tersedia, sehingga beban dan jeda sama-sama bergantung pada seberapa sering ia bertanya. Long polling mengirim request yang ditahan server sampai ada pembaruan; server menjawab dengan pembaruan itu, lalu klien langsung mengirim request baru.

Server-Sent Events (SSE) mengubah satu GET menjadi respons yang tetap terbuka. Server menjawab dengan tipe text/event-stream dan menulis event ke respons itu setiap kali ada kejadian, lalu API EventSource di browser mem-parse-nya. WebSocket dimulai sebagai request HTTP yang membawa Upgrade: websocket; server menjawab 101 Switching Protocols, dan sejak itu, seperti dirumuskan RFC 6455, satu koneksi TCP membawa trafik ke dua arah.

## Ke mana arah datanya, dan apa yang ditahan terbuka oleh masing-masing?

Tentukan dulu dari arahnya. MDN menyebut server-sent events bersifat satu arah, dari server ke klien, berbeda dengan WebSocket; klien tetap membalas lewat request biasa. WebSocket adalah satu-satunya yang memang dibuat untuk trafik dua arah, dan itu bermanfaat saat klien juga sering mengirim, misalnya chat yang menampilkan siapa yang sedang mengetik.

Lalu hitung apa yang tetap terbuka. Polling tidak menahan apa pun di antara request, tetapi setiap request adalah request HTTP utuh dan sebagian besar jawabannya kosong. Long polling menahan satu request untuk setiap klien yang menunggu. RFC 6202 mencatat biayanya: setiap pesan adalah pesan HTTP lengkap beserta header-nya, dan jeda terburuknya lebih dari tiga kali perjalanan jaringan, karena pesan yang tiba tepat setelah sebuah jawaban harus menunggu request berikutnya. SSE dan WebSocket menahan satu koneksi panjang per klien dan mengirim setiap pesan tanpa header HTTP baru.

Browser juga punya batasnya sendiri. Jika SSE tidak berjalan di atas HTTP/2, MDN mengingatkan bahwa koneksi EventSource yang terbuka dibatasi 6 per browser dan domain, dipakai bersama oleh semua tab. Di HTTP/2, server dan klien menegosiasikan jumlah stream yang diizinkan, defaultnya 100. Dashboard yang membuka satu stream per widget, di beberapa tab, akan menabrak batas itu di HTTP/1.1.

## Bagaimana stream bertahan saat koneksi putus?

EventSource tersambung ulang dengan sendirinya; WebSocket tidak. Saat sebuah event stream putus, browser menunggu selama reconnection time lalu membukanya lagi. Standar HTML menyerahkan nilai awalnya ke browser, kemungkinan beberapa detik, dan server bisa mengubahnya lewat baris retry: dalam milidetik. Standar WebSocket sama sekali tidak punya langkah reconnect: socket yang tertutup tetap CLOSED, jadi klien WebSocket butuh loop reconnect sendiri, lengkap dengan backoff dan jitter.

Tersambung lagi baru separuh pekerjaan; separuh lainnya adalah tidak kehilangan event. Jika server menandai setiap event dengan baris id:, EventSource mengingat id terakhir dan mengirimnya di header Last-Event-ID pada koneksi berikutnya, sehingga server bisa melanjutkan tepat setelahnya, asalkan riwayatnya masih disimpan. Pada try-it di bawah, browser kembali dengan Last-Event-ID: 3 dan stream berlanjut di event 4.

Server juga yang menentukan kapan berhenti. Jawaban 204 No Content menyuruh EventSource berhenti mencoba tersambung, begitu juga status apa pun selain 200 atau Content-Type selain text/event-stream. Standar menyebutnya failing the connection, dan setelah itu browser tidak mencoba lagi.

## Kenapa stream yang lancar di lokal macet di belakang proxy?

Karena setiap hop harus meneruskan setiap event begitu event itu tiba, dan tetap sabar saat tidak ada yang datang. HTTP tidak mewajibkan perantara meneruskan respons parsial seketika: RFC 6202 mencatat bahwa proxy boleh mem-buffer seluruh respons, dan streaming HTTP tidak jalan melewati proxy seperti itu. Panduan self-hosting Next.js menyampaikan hal yang sama untuk nginx dan menunjukkan perbaikan yang dianjurkannya, yaitu header respons X-Accel-Buffering: no.

nginx mem-buffer respons yang di-proxy secara default (proxy_buffering on), dan sebuah respons bisa mematikannya untuk dirinya sendiri dengan X-Accel-Buffering: no. Dalam uji lokal dengan nginx 1.31.7, pengaturan default tetap meneruskan event satu per detik, sesuai saat server menulisnya. Yang menahannya justru kompresi: dengan gzip aktif untuk text/event-stream, curl tidak menerima apa pun selama 45 detik, dan X-Accel-Buffering: no membuat nginx langsung meneruskan setiap event, tetap dalam bentuk terkompresi. Periksa setiap lapisan, bukan hanya proxy yang Anda curigai.

Idle timeout memutus koneksi yang sepi. proxy_read_timeout milik nginx, defaultnya 60 detik, menutup koneksi saat upstream tidak mengirim apa pun di antara dua kali baca, dan panduan WebSocket nginx menyebut 60 detik yang sama serta menyarankan ping frame. Cloudflare menutup WebSocket saat tidak ada data yang bergerak ke arah mana pun selama beberapa waktu, bisa memutus WebSocket saat me-restart server untuk rilis, dan menjawab 524 saat origin tidak mengirim respons dalam proxy read timeout 125 detik miliknya. Obatnya adalah heartbeat: standar HTML menyarankan baris komentar, yaitu baris yang diawali titik dua, kira-kira setiap 15 detik.

WebSocket butuh satu hal lagi dari proxy. Upgrade dan Connection adalah header hop-by-hop, jadi nginx tidak meneruskannya ke upstream kecuali diperintahkan: location untuk WebSocket butuh proxy_set_header Upgrade $http_upgrade dan proxy_set_header Connection "upgrade", ditambah proxy_http_version 1.1 untuk nginx yang lebih lama dari 1.29.7. SSE tidak butuh semua itu, karena ia hanyalah respons biasa.

## Kenapa chatbot situs ini mengalirkan SSE lewat POST?

Karena jawaban chat berbentuk satu pertanyaan naik dan banyak potongan kecil turun, bentuk yang cocok untuk SSE. Route meneruskan pertanyaan ke model provider dan, setiap kali token kembali, menulisnya sebagai baris data: berisi objek JSON kecil dengan delta, lalu event done, atau event error dengan sebuah kode. Balasannya HTTP biasa dengan Content-Type text/event-stream, jadi tidak perlu langkah upgrade di proxy mana pun, dan request yang ditolak sebelum streaming, oleh rate limit atau validasi, tetap mendapat error JSON biasa.

Chatbot ini tidak bisa memakai EventSource. Constructor EventSource hanya menerima URL dan satu opsi, withCredentials, dan langkah request di standarnya tidak pernah menyetel method maupun body, jadi ia hanya bisa mengirim GET tanpa body. Chatbot mengirim pesan sampai 1000 karakter ditambah beberapa giliran percakapan terakhir, yang tempatnya di body POST. Jadi ia memanggil fetch dengan POST, mengambil response.body yang berupa ReadableStream, lalu membacanya potong demi potong lewat getReader().

Membaca stream sendiri berarti mengerjakan parsing yang biasanya dilakukan EventSource secara manual, dan lib/api/sse.ts menunjukkan intinya dalam beberapa baris. Batas chunk bisa jatuh di mana saja, bahkan di tengah sebuah event, jadi parser memecah di baris kosong dan menyimpan sisa yang belum lengkap untuk chunk berikutnya. Decoder dijalankan dengan stream: true, yang memberi tahu bahwa masih ada data menyusul; dalam uji lokal, men-decode dua byte huruf é di dua panggilan terpisah tanpa opsi itu menghasilkan dua karakter pengganti, sedangkan dengan opsi itu hasilnya é.

Yang dikorbankan adalah reconnect dan Last-Event-ID milik EventSource. Pertukaran itu cocok untuk jawaban chat: route tidak mengirim baris id: dan tidak menyimpan riwayat, jadi memang tidak ada yang bisa dilanjutkan. Jika server melaporkan kegagalan di tengah jawaban, chatbot menyimpan balasan parsialnya dan menandainya terputus, lalu pengunjung bertanya lagi.

## Kenapa WebSocket butuh state bersama untuk scaling?

Karena koneksi yang panjang umurnya dimiliki satu proses server, sedangkan kabar barunya bisa mendarat di proses lain. Dengan dua app server di belakang load balancer, pesan yang dikirim lewat server A harus sampai ke klien yang socket-nya dipegang server B. Jawaban yang umum adalah kanal pub/sub yang di-subscribe semua server, misalnya Redis Pub/Sub: setiap server mem-publish apa yang diterimanya dan meneruskan apa yang didengarnya ke klien miliknya sendiri. Socket.IO menyebut bagian yang merutekan pesan antar server itu adapter.

Pahami apa yang dijanjikan kanal itu. Redis mendokumentasikan Pub/Sub sebagai pengiriman at-most-once: subscriber yang sedang terputus saat pesan dikirim tidak akan pernah menerimanya, dan Redis Streams tersedia untuk jaminan yang lebih kuat. Long polling menambah masalah routing: Socket.IO mencatat bahwa transport long-polling miliknya mengirim beberapa request HTTP dalam satu sesi yang semuanya harus sampai ke server yang sama, sehingga butuh sticky session; setup yang hanya memakai WebSocket tidak butuh.

SSE di-scale dengan cara yang sama saat event dari satu klien harus sampai ke klien lain. Chatbot situs ini menghindari masalahnya karena memang tidak ada yang perlu dibagi: setiap stream menjawab pertanyaan satu pengunjung, dan tidak ada event yang dikirim ke pengunjung kedua. Rate limiter di sebelahnya lain cerita: seperti dibahas di pelajaran reliability, hitungannya tersimpan di memori satu container dan butuh penyimpanan bersama sebelum salinan kedua bisa dijalankan.

## Konfigurasi nyata di balik pelajaran ini

`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`),
        );
```

Seluruh format kabel chatbot ada di baris-baris ini. Setiap token dari model menjadi satu event: baris data: berisi JSON, diakhiri baris kosong yang dibentuk oleh \n ganda. Setelah token terakhir datang satu event done, dan blok catch di bawahnya mengirim event error sebagai gantinya. Tidak ada baris id: atau event:, karena tidak ada yang perlu dilanjutkan dan setiap event bertipe sama.

`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 yang menjadikan respons ini sebuah event stream. no-cache mencegah cache menjawab request lain dengan balasan ini kecuali divalidasi ulang dulu. no-transform meminta perantara tidak mengubah body-nya, dan middleware kompresi bawaan Next.js 15.5.7 melewati respons apa pun yang membawanya. Namun modul gzip nginx tidak memeriksanya: dalam uji lokal, stream dengan no-transform dan gzip aktif untuk event stream tetap tidak mengirim apa-apa. Perhatikan yang tidak ada: 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 };
```

Pecah di baris kosong, lalu pop: potongan terakhir adalah apa pun yang datang setelah baris kosong terakhir, yaitu event yang masih dalam perjalanan, jadi ia dikembalikan sebagai rest untuk chunk berikutnya. Saat diberi event yang terbelah di dua chunk, fungsi ini mengembalikan separuh pertama sebagai rest dan event utuhnya di panggilan berikutnya. Ini bukan parser SSE lengkap: ia hanya membaca satu baris data: per event dan memecah di \n saja, jadi stream ber-CRLF atau field data multi-baris tidak menghasilkan apa-apa. Itu tidak masalah di sini, karena repository yang sama menulis kedua ujungnya.

`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;
```

Sandbox API di /developers membaca balasan POST dengan cara ini: response.body adalah ReadableStream, dan setiap read() selesai saat chunk berikutnya tiba, jadi jawabannya tumbuh di layar selagi model masih menulis. stream: true menjaga karakter yang terbelah di dua chunk tetap utuh, dan apa pun yang belum bisa diselesaikan parser tetap tersimpan di buffer. Widget chat melakukan hal yang sama dengan salinan parser miliknya sendiri yang ditulis langsung di komponennya.

## Yang sering rusak

- **Lapisan yang menahan semua event sampai akhir.** Event mengalir lancar di localhost tetapi di production tiba sekaligus, atau tidak pernah tiba. Ada hop yang mem-buffer atau mengompres responsnya. Dalam uji lokal, nginx dengan gzip aktif untuk text/event-stream tidak mengirim apa pun ke curl selama 45 detik. Kirim X-Accel-Buffering: no, keluarkan text/event-stream dari gzip_types, dan uji lewat setiap hop, termasuk CDN.
- **Idle timeout memutus stream yang sepi.** Stream yang tidak punya isi untuk dikirim akan ditutup oleh hop pertama yang timer-nya habis. Dengan proxy_read_timeout 3s dalam uji lokal, curl menerima komentar pertama lalu diputus setelah 3,0 detik, dan nginx mencatat upstream timed out while reading upstream. Baris komentar setiap 2 detik membuat stream yang sama tetap terbuka. Kirim heartbeat yang lebih pendek dari idle timeout terpendek di sepanjang jalur.
- **EventSource tidak bisa POST.** Satu-satunya opsinya adalah withCredentials: tidak ada method, body, maupun header kustom. Stream yang butuh request besar, atau header Authorization, tidak bisa dimulai dari EventSource. Kirim dulu pekerjaannya lewat POST lalu buka stream GET untuk id-nya, atau lakukan seperti situs ini: fetch dengan POST lalu parse event stream dari response.body.
- **Header yang tidak pernah keluar dari server.** Dalam uji lokal, server Node yang memanggil writeHead lalu menunggu 5 detik untuk event pertamanya tidak mengirim apa pun, dan nginx menjawab 504 setelah timeout 3 detiknya, tercatat sebagai timed out while reading response header. Langsung tulis sesuatu, baris retry atau komentar, seperti yang dilakukan server try-it.
- **WebSocket yang tetap tertutup setelah restart.** Deploy yang me-restart server, atau CDN yang me-restart server miliknya, menutup semua socket, dan API browser tidak akan membukanya kembali. Lakukan reconnect di kode Anda sendiri dengan backoff ber-jitter, supaya klien tidak kembali di detik yang sama, lalu ambil apa yang terlewat, karena socket tidak membawa Last-Event-ID.
- **Yang akan saya ubah di sini: satu parser, satu header.** Widget chat mem-parse stream dengan salinan parser miliknya sendiri, yang hanya menerima data: diikuti spasi, sedangkan sandbox memakai lib/api/sse.ts. Satu parser bersama akan menjaga keduanya tetap seirama. Route ini juga tidak mengirim X-Accel-Buffering: no; apakah proxy produksi mem-buffer adalah pengaturan server di luar repository ini, dan header itu akan membuat route tetap mengalir di belakang nginx mana pun.

### Polling, long polling, SSE, atau WebSocket: biaya dan kecocokan masing-masing

|  | Polling | Long polling | SSE | WebSocket |
| --- | --- | --- | --- | --- |
| Arah | Klien bertanya terjadwal | Klien bertanya, server menjawab saat siap | Server ke klien | Dua arah |
| Yang ditahan | Tidak ada di antara request | Satu request per klien yang menunggu | Satu respons per klien | Satu socket per klien |
| Jeda kabar baru | Sampai satu interval | Singkat, tetapi request baru setiap pesan | Begitu ditulis | Begitu dikirim |
| Reconnect | Setiap request baru | Request berikutnya | Bawaan, dengan Last-Event-ID | Kode Anda sendiri |
| Lewat proxy | Request biasa | Request biasa; menunggu lama kena timeout | Respons biasa; tanpa buffering dan perlu heartbeat | Header Upgrade diteruskan, ping untuk idle timer |
| Cocok untuk | Perubahan jarang, cek status sederhana | Cadangan saat cara lain tidak tembus | Feed, notifikasi, jawaban AI yang mengalir | Chat dengan status mengetik, game, kolaborasi langsung |

## Coba sendiri

Anda butuh Node 22 atau lebih baru, curl, dan browser; langkah proxy yang opsional juga butuh Docker. Semuanya listen di 127.0.0.1, dan tidak ada yang mengirim request ke situs ini.

1. Simpan server ini sebagai sse.mjs lalu jalankan dengan node sse.mjs di terminal tersendiri. Server ini mengirim satu event per detik, mulai setelah Last-Event-ID yang diterimanya, dan menyajikan halaman yang mendengarkan dengan 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. Lihat stream mentahnya. -N mematikan buffering output milik curl, jadi setiap event tercetak begitu tiba: satu baris retry, lalu baris id dan data setiap detik, masing-masing diikuti baris kosong. Hentikan dengan Ctrl+C.

   ```bash
   curl -N http://127.0.0.1:8080/events
   ```
3. Lanjutkan stream seperti yang dilakukan EventSource setelah putus. Server mencatat header yang diterimanya, dan stream dimulai dari 42.

   ```bash
   curl -N -H "Last-Event-ID: 41" http://127.0.0.1:8080/events
   ```
4. Buka http://127.0.0.1:8080 di Chrome, lalu DevTools, Network, pilih request events dan buka tab EventStream: event muncul begitu tiba. Di Firefox, tab Response pada request yang sama menampilkan daftarnya.
5. Dengan halaman tetap terbuka, hentikan server dengan Ctrl+C lalu jalankan lagi. Halaman mencatat lost, readyState 0, tersambung lagi setelah retry 2 detik, dan server mencetak Last-Event-ID yang dikirim browser; nomornya melanjutkan dari tempat terakhir.

   ```bash
   node sse.mjs
   ```
6. Opsional, dengan Docker: simpan ini sebagai nginx.conf di sebelah sse.mjs. File ini mengaktifkan gzip untuk event stream, satu baris yang tampak tidak berbahaya.

   ```bash
   server {
     listen 80;
     gzip on;
     gzip_types text/event-stream;
     location / {
       proxy_pass http://sse-app:8080;
     }
   }
   ```
7. Jalankan server dan nginx sebagai container di satu network, lalu minta lewat nginx seperti browser, dengan menerima gzip. curl tidak mencetak satu event pun, dan --max-time menghentikannya setelah 15 detik.

   ```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. Di sse.mjs, hapus dua garis miring di depan X-Accel-Buffering, restart kedua container, lalu minta lagi: satu event per detik, tetap terkompresi, karena nginx kini meneruskan respons begitu diterima.

   ```bash
   docker restart sse-app sse-proxy
   sleep 2
   curl -N --compressed --max-time 5 http://127.0.0.1:8081/events
   ```
9. Bersihkan container dan network-nya, lalu hentikan node sse.mjs dengan Ctrl+C.

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

## Kuis singkat

1. **Sebuah dashboard menampilkan jumlah order secara live. Server mendorong pembaruan; browser tidak mengirim apa pun selain saat memuat halaman. Mana yang paling sedikit bagian bergeraknya?**
   A. WebSocket
   B. Server-Sent Events
   C. Polling setiap detik
   D. Long polling
   Jawaban: B. Datanya mengalir satu arah, dari server ke browser, persis yang dibawa SSE. SSE adalah respons HTTP biasa, tidak butuh upgrade di proxy, dan EventSource tersambung ulang sendiri. WebSocket hanya menambah saluran dua arah yang tidak dipakai siapa pun.

2. **Event mengalir lancar di localhost, tetapi di belakang nginx di production semuanya tiba sekaligus di akhir. Apa yang Anda periksa lebih dulu?**
   A. Naikkan proxy_read_timeout
   B. Ganti ke WebSocket
   C. Buffering atau gzip di jalur, dan X-Accel-Buffering: no
   D. Tambahkan baris id: ke setiap event
   Jawaban: C. Semua tiba bersamaan di akhir berarti ada hop yang menahan respons, dengan mem-buffer atau mengompresnya. Timeout akan memutus stream, bukan menundanya, dan id hanya berguna untuk melanjutkan setelah putus.

3. **Sebuah EventSource tersambung lagi setelah koneksinya putus. Bagaimana server tahu harus melanjutkan dari mana?**
   A. Browser mengirim id terakhir yang dilihatnya di header Last-Event-ID
   B. Browser memutar ulang semua event yang diterimanya
   C. Proxy mengingat posisinya
   D. Tidak bisa; SSE selalu mulai dari awal
   Jawaban: A. EventSource menyimpan nilai baris id: terakhir dan mengirimnya sebagai Last-Event-ID saat tersambung lagi. Server tetap harus menyimpan riwayat yang cukup untuk melanjutkan dari situ.

## Pikirkan dulu

- **Kenapa chatbot situs ini tidak bisa memakai EventSource?** EventSource hanya mengirim GET tanpa body, dan satu-satunya opsinya adalah withCredentials. Chatbot perlu mengirim pesan dan beberapa giliran percakapan terakhir lewat POST, jadi ia memakai fetch dengan POST dan mem-parse sendiri event stream dari response.body.
- **Chat WebSocket berjalan baik di satu server tetapi kehilangan pesan di dua server. Kenapa?** Setiap socket hidup di satu server, jadi pesan yang diterima server A tidak pernah sampai ke klien yang terhubung ke server B. Semua server perlu berbagi pesan lewat sesuatu seperti Redis Pub/Sub, dan long polling juga butuh sticky session.
- **Apa fungsi baris yang diawali titik dua di event stream, dan kenapa perlu dikirim?** Itu adalah komentar, dan EventSource mengabaikannya. Jika dikirim kira-kira setiap 15 detik, seperti saran standar HTML, baris itu menjaga ada byte yang mengalir, sehingga proxy yang idle timeout-nya lebih panjang dari jeda itu tidak pernah menutup stream karena dianggap sepi.

## Pelajari lebih dalam

- [Server-Sent Events vs WebSockets untuk Realtime](https://www.matthewswong.com/id/blog/server-sent-events-vs-websockets-realtime/)
- [Server-Sent Events di Next.js: Lebih Sederhana dari WebSocket](https://www.matthewswong.com/id/blog/server-sent-events-nextjs-streaming/)
- [Menskalakan WebSocket dengan Redis Pub/Sub: Dari Satu Server ke Banyak](https://www.matthewswong.com/id/blog/websocket-scaling-redis-pubsub/)
- [WebSockets di NestJS: Membangun Fitur Real-Time di Produksi](https://www.matthewswong.com/id/blog/websockets-nestjs-realtime/)

## Sumber

- [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 (API browser)](https://websockets.spec.whatwg.org/)
- [RFC 6202: long polling dan streaming pada HTTP dua arah](https://www.rfc-editor.org/rfc/rfc6202.html)
- [MDN: EventSource (batas koneksi)](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
- [MDN: constructor EventSource()](https://developer.mozilla.org/en-US/docs/Web/API/EventSource/EventSource)
- [MDN: memakai server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
- [MDN: WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)
- [MDN: Response.body (sebuah ReadableStream)](https://developer.mozilla.org/en-US/docs/Web/API/Response/body)
- [MDN: memakai readable stream](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams)
- [MDN: TextDecoder.decode() dan opsi stream](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/decode)
- [Fetch Standard: method dan body default sebuah request](https://fetch.spec.whatwg.org/)
- [Dokumentasi nginx: proxy_buffering, proxy_read_timeout, proxy_http_version](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
- [Dokumentasi nginx: proxy untuk WebSocket](https://nginx.org/en/docs/http/websocket.html)
- [Dokumentasi nginx: modul gzip (gzip_types)](https://nginx.org/en/docs/http/ngx_http_gzip_module.html)
- [Dokumentasi Cloudflare: WebSockets](https://developers.cloudflare.com/network/websockets/)
- [Dokumentasi Cloudflare: batas koneksi (proxy read timeout)](https://developers.cloudflare.com/fundamentals/reference/connection-limits/)
- [Dokumentasi Next.js: self-hosting, streaming, dan 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)
- [Dokumentasi Redis: semantik pengiriman Pub/Sub](https://redis.io/docs/latest/develop/pubsub/)
- [Dokumentasi Socket.IO: memakai beberapa node](https://socket.io/docs/v4/using-multiple-nodes/)
- [Chrome DevTools: referensi Network (tab EventStream)](https://developer.chrome.com/docs/devtools/network/reference)
- [Firefox DevTools: memeriksa 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/id/learn/system-design/websocket-vs-sse-vs-long-polling/
- English: https://www.matthewswong.com/en/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
