Server-Sent Events (SSE) adalah satu response HTTP yang gak pernah selesai: server terus menulis kabar ke dalamnya sebagai baris teks, dan EventSource di browser membaca tiap kabar jadi event. Bayangin pengeras suara dari ruang cuci ke meja depan. Mas Eko mengumumkan tiap perubahan dengan nomor: "Nomor 41: dua belas, disetrika." Orang di depan cuma bisa mendengar, gak bisa menjawab lewat pengeras suara itu. Kalau kabelnya copot, kabelnya dicolok lagi beberapa detik kemudian, dan Bu Rini bilang "yang terakhir kudengar nomor 41", jadi Mas Eko mengulang semua pengumuman sesudah 41.
🧪 Tebakan cepat: Koneksi SSE papan Bu Rini putus beberapa detik. Selama putus, Mas Eko mengubah #13 jadi Siap diambil. Setelah tersambung lagi, apakah papan tahu soal #13? Jawabannya ada di bagian "Putus dan sambung lagi: Last-Event-ID".
Format text/event-stream
Server menjawab dengan Content-Type: text/event-stream, lalu menulis event demi event tanpa menutup response-nya:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Access-Control-Allow-Origin: http://localhost:8000
: papan Laundry Bu Rini
event: status
data: {"id":12,"status":"Disetrika"}
id: 41
| Baris | Artinya |
|---|---|
data: ... |
isi event. Beberapa baris data: berturut-turut digabung dengan baris baru (\n) |
event: status |
nama event. Tanpa baris ini, namanya message |
id: 41 |
nomor event. Browser mengingatnya sebagai Last-Event-ID |
retry: 2000 |
jeda reconnect yang diminta server, dalam milidetik |
: teks |
komentar, diabaikan browser. Dipakai sebagai heartbeat supaya koneksi gak dianggap diam |
| baris kosong | event selesai: kirim ke browser sekarang |
Satu spasi sesudah titik dua dibuang, field yang gak dikenal diabaikan, dan isinya selalu teks UTF-8. Event status di atas 59 byte, dan di kabel HTTP/1.1 jadi 65 byte (ditambah penanda panjang potongan). Kalau server menjawab dengan Content-Type lain, misalnya application/json, EventSource langsung menyerah: diuji di ketiga browser, cuma satu request dan gak ada reconnect.
Server: Node.js tanpa library
import http from 'node:http'
const layar = new Set() // response yang masih terbuka
const riwayat = [] // event terakhir, untuk dikirim ulang
let nomor = 40
http.createServer((req, res) => {
if (req.url !== '/papan/stream') return res.writeHead(404).end()
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': 'http://localhost:8000',
})
const terakhir = req.headers['last-event-id']
if (terakhir) for (const e of riwayat) if (e.id > Number(terakhir)) res.write(e.teks)
layar.add(res)
req.on('close', () => layar.delete(res))
}).listen(3000)
// Dipanggil saat Mas Eko mengubah status
function kirimStatus(id, status) {
nomor++
const teks = `event: status\ndata: ${JSON.stringify({ id, status })}\nid: ${nomor}\n\n`
riwayat.push({ id: nomor, teks })
if (riwayat.length > 100) riwayat.shift()
for (const res of layar) res.write(teks)
}
setInterval(() => {
for (const res of layar) res.write(': ping\n\n') // heartbeat
}, 25000)
res.write(...)tanpares.end(): response-nya dibiarkan terbuka.\n\ndi akhir tiap event adalah baris kosong yang membuat browser mengirim event-nya.riwayatmenyimpan 100 event terakhir, supaya yang terlewat bisa dikirim ulang.- Komentar
: pingtiap 25 detik menjaga koneksi tetap "bicara" di depan proxy yang memutus koneksi diam (nginx: 60 detik).
EventSource di browser
const sumber = new EventSource('http://localhost:3000/papan/stream')
sumber.addEventListener('status', (event) => {
const { id, status } = JSON.parse(event.data)
document.querySelector(`#status-${id}`).textContent = status
console.log('event nomor', event.lastEventId) // "41"
})
sumber.onopen = () => console.log('tersambung')
sumber.onerror = () => console.log('putus, readyState', sumber.readyState)
- Event bernama (
event: status) cuma sampai lewataddEventListener('status', ...).onmessagehanya menerima event tanpa barisevent:. readyState: 0 CONNECTING (juga saat sedang menyambung ulang), 1 OPEN, 2 CLOSED.sumber.close()menutup selamanya, tanpa reconnect.
Dibanding WebSocket, SSE lebih simpel untuk push satu arah: cuma HTTP biasa, gak butuh protokol atau library khusus, EventSource sudah ada di browser, dan reconnect-nya otomatis.
Putus dan sambung lagi: Last-Event-ID
Kalau koneksinya putus, EventSource menyambung lagi sendiri, dan request barunya membawa header Last-Event-ID berisi nomor event terakhir yang dia terima. Diuji di Chromium 147:
| Waktu | Yang terjadi |
|---|---|
| 0 dtk | papan menerima event 41: #12 Disetrika |
| 1 dtk | proxy memutus koneksi. Event error, readyState 0 |
| 2 dtk | Mas Eko mengubah #13 jadi Siap diambil. Server menyimpan event 42 di riwayat |
| 4 dtk | browser menyambung lagi (3 detik setelah putus) dengan header Last-Event-ID: 41 |
| 4 dtk | server mengirim ulang event 42, papan menampilkan #13 Siap diambil |
Jadi papan tahu, asal server menulis id: dan mengirim ulang event sesudah Last-Event-ID. Itu jawaban tebakannya. Tanpa id:, browser gak mengirim Last-Event-ID, server gak tahu apa yang terlewat, dan #13 tetap salah sampai ada kabar berikutnya.
Jeda reconnect-nya diukur: Chromium 147 dan Safari (WebKit 26.4) menunggu 3 detik, Firefox 146 menunggu 5 detik. Baris retry: 2000 dari server mengubahnya jadi sekitar 2 detik di ketiganya. Untuk menghentikan reconnect dari server, jawab dengan 204 No Content: EventSource berhenti di readyState 2.
Yang gak bisa dilakukan EventSource
- Header sendiri.
new EventSource(url, options)cuma kenalwithCredentials. Gak adaAuthorization: Bearer .... Pakai cookie, atau token berumur pendek di query (URL tercatat di log server). Kalau server cuma menerima header, pakaifetchdan bacaresponse.bodysepotong-sepotong, tapi parsing dan reconnect-nya kamu tulis sendiri. - POST atau body. EventSource selalu GET. Untuk mengirim status dari tablet Mas Eko, pakai
fetchbiasa. - Dua arah dan biner. Arahnya cuma server ke browser, isinya cuma teks. Biner harus di-base64.
- CORS tanpa izin. Papan di
localhost:8000, stream dilocalhost:3000: beda origin. TanpaAccess-Control-Allow-Origin, koneksinya gagal dan gak dicoba lagi. Dengannew EventSource(url, { withCredentials: true }), cookie ikut dikirim, tapi server harus menyebut origin-nya persis (bukan*) dan menambahAccess-Control-Allow-Credentials: true. Diuji di ketiga browser:*dengan credentials gagal.
⚠️ Jebakan yang sering ketemu
- Lupa baris kosong. Tanpa
\n\n, browser terus menunggu dan event-nya gak pernah sampai. onmessageuntuk event bernama. Stream yang menulisevent: statusgak pernah memicuonmessage.- Gak menulis
id:. Yang terjadi selama putus hilang begitu saja. - Proxy yang menampung atau memadatkan. Event datang bergerombol. Kirim header
X-Accel-Buffering: nountuk nginx, jangan gzip stream-nya, dan kirim komentar heartbeat.
TL;DR: SSE adalah satu response Content-Type: text/event-stream yang terus terbuka. Tiap event berupa baris data: (beberapa baris digabung dengan \n), opsional event: (tanpa itu namanya message), id:, dan retry:, ditutup baris kosong. Baris berawalan : adalah komentar untuk heartbeat. EventSource menerima event bernama lewat addEventListener, menyambung ulang sendiri (3 detik di Chromium dan Safari, 5 detik di Firefox, atau sesuai retry:), dan mengirim Last-Event-ID supaya server bisa mengirim ulang yang terlewat. Server menghentikannya dengan 204. EventSource gak bisa memasang header sendiri, gak bisa POST, cuma satu arah dan teks, dan lintas origin butuh CORS (dengan withCredentials, origin harus disebut persis). Untuk push satu arah, SSE lebih simpel dari WebSocket: HTTP biasa, API bawaan, reconnect otomatis.