Panel monitoring di kantor saya menampilkan log yang terus mengalir — baris demi baris, tanpa tombol refresh. Angka CPU, request masuk, error yang muncul, semuanya bergerak sendiri. Di balik layar, browser tidak melakukan polling tiap detik. Server-nya yang mendorong data, lewat satu koneksi HTTP biasa. Teknik ini bernama Server-Sent Events, atau SSE.

SSE cocok untuk notifikasi, live log, skor pertandingan, sampai streaming token dari model bahasa. Kita mulai dari endpoint paling sederhana, lalu lanjut ke named events, streaming token LLM, dan satu jebakan Nginx yang sering bikin orang bingung.

Apa Itu Server-Sent Events?

SSE muncul sebagai bagian dari spesifikasi HTML5. Prinsipnya sederhana: server mengirim potongan data ke browser lewat respons HTTP yang tidak pernah ditutup. Klien membuka koneksi sekali, lalu tinggal mendengarkan. Protokolnya memakai header Content-Type: text/event-stream, dan tiap pesan dipisahkan oleh satu baris kosong.

Berbeda dari request biasa yang model request–response, di SSE server punya kendali penuh. Begitu ada data baru, server langsung menulis ke koneksi yang masih terbuka, dan browser memicu callback JavaScript. Arahnya satu: server ke klien.

SSE vs WebSocket: Jangan Salah Pilih

SSE dan WebSocket sama-sama real-time, tapi arah komunikasinya beda. SSE murni satu arah — server mendorong data, klien cuma menerima. WebSocket dua arah: klien dan server bisa saling kirim kapan saja.

Untuk notifikasi, live log, dan streaming token LLM, SSE justru lebih sederhana. Tidak ada handshake WebSocket, tidak ada frame biner, dan EventSource bawaan browser sudah punya reconnect otomatis. Kalau aplikasimu butuh chat dua arah atau game real-time, barulah WebSocket masuk akal — saya sudah menulis tutorial lengkapnya di Membangun Aplikasi Real-Time dengan WebSocket di Python (FastAPI).

Aturan praktis: data yang mengalir satu arah dan sering = SSE. Percakapan bolak-balik = WebSocket.

Siapkan Proyek

Kita pakai FastAPI karena generator async-nya pas untuk streaming. Kalau kamu belum pernah menyentuh FastAPI, mulai dari Cara Membuat REST API dengan FastAPI dulu.

bash
mkdir sse-demo && cd sse-demo
python3 -m venv .venv
source .venv/bin/activate
pip install fastapi uvicorn

Endpoint SSE Paling Sederhana

Buat file main.py. Kuncinya ada di StreamingResponse dan generator async yang menuliskan format pesan SSE secara manual.

import asyncio
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()


async def event_stream():
    for i in range(10):
        yield f"data: pesan ke-{i}\n\n"
        await asyncio.sleep(1)


@app.get("/events")
async def events():
    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        },
    )

Format pesannya ketat: tiap event diawali baris data:, lalu diakhiri dua karakter newline. Tanpa pemisah itu browser tidak tahu di mana satu pesan berhenti dan yang berikutnya mulai. Header Cache-Control: no-cache mencegah proxy menyimpan respons, dan X-Accel-Buffering: no khusus buat Nginx agar tidak menahan output di buffer.

Jalankan dengan uvicorn main:app --reload. Kalau kamu buka http://127.0.0.1:8000/events langsung di browser, yang tampil adalah teks mentah event-stream — itu normal. Yang kita mau adalah memakainya lewat EventSource, seperti contoh berikut.

Klien di Browser dengan EventSource

Di sisi klien kamu tidak butuh library apa pun. Browser modern punya objek EventSource yang menangani koneksi, parsing, dan reconnect.

html
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="utf-8">
  <title>SSE Demo</title>
</head>
<body>
  <ul id="log"></ul>

  <script>
    const log = document.getElementById("log");
    const source = new EventSource("/events");

    source.onmessage = (event) => {
      const li = document.createElement("li");
      li.textContent = event.data;
      log.appendChild(li);
    };

    source.onerror = () => {
      console.log("Koneksi putus, EventSource coba reconnect...");
    };
  </script>
</body>
</html>

Supaya bisa diakses lewat FastAPI, tambahkan route kecil untuk menyajikan file ini. Simpan HTML di atas sebagai index.html di folder yang sama dengan main.py.

python
from fastapi.responses import HTMLResponse


@app.get("/")
async def index():
    return HTMLResponse(open("index.html").read())

Restart uvicorn, buka http://127.0.0.1:8000/, dan lihat daftar pesan bertambah satu per detik. Saat koneksi putus — server restart atau jaringan melompat — EventSource otomatis mencoba menyambung lagi setelah jeda beberapa detik. Kamu tidak perlu menulis logika retry sendiri.

Named Events dan Kontrol Reconnect

Sejauh ini semua pesan masuk lewat onmessage. Padahal kamu bisa memberi nama tiap event supaya klien memilih mana yang mau didengarkan. Berguna ketika satu endpoint mengirim beberapa jenis data sekaligus.

python
async def event_stream():
    yield 'event: notifikasi\ndata: {"level": "info", "pesan": "server mulai"}\n\n'
    await asyncio.sleep(2)
    yield 'event: notifikasi\ndata: {"level": "error", "pesan": "disk hampir penuh"}\n\n'
javascript
source.addEventListener("notifikasi", (event) => {
  const data = JSON.parse(event.data);
  console.log(`[${data.level}] ${data.pesan}`);
});

Untuk mengatur ulang koneksi, server bisa mengirim baris retry: 3000 agar browser menunggu 3 detik sebelum mencoba lagi. Kirim id: pada tiap event, dan browser akan menyertakan header Last-Event-ID saat reconnect — server jadi tahu pesan terakhir yang sudah diterima klien.

Contoh Nyata: Streaming Token LLM

Kasus SSE paling populer belakangan adalah streaming jawaban dari model bahasa. Token muncul satu per satu, seperti yang kamu lihat di ChatGPT. Polanya sama persis dengan endpoint sederhana tadi, cuma generatornya menghasilkan potongan teks.

python
async def stream_llm(prompt: str):
    jawaban = ["Halo", ", ", "saya", " asisten", " AI", ".", " Ada", " yang", " bisa", " saya", " bantu", "?"]
    for token in jawaban:
        yield f"data: {token}\n\n"
        await asyncio.sleep(0.15)


@app.get("/chat")
async def chat(prompt: str):
    return StreamingResponse(stream_llm(prompt), media_type="text/event-stream")
javascript
const source = new EventSource("/chat?prompt=jelaskan+apa+itu+sse");
let buffer = "";
source.onmessage = (event) => {
  buffer += event.data;
  document.getElementById("jawaban").textContent = buffer;
};

EventSource hanya mendukung GET. Kalau kamu perlu mengirim body JSON besar — prompt panjang atau riwayat percakapan — pakai fetch dengan ReadableStream dan parsing manual, atau cukup pakai query parameter untuk kasus sederhana seperti di atas.

Jebakan Produksi: Nginx dan Buffering

Satu masalah yang bikin orang mengira SSE-nya rusak adalah buffering proxy. Nginx, secara default, menahan output dari aplikasi sampai terkumpul cukup banyak sebelum dikirim ke klien. Akibatnya pesan yang harusnya muncul tiap detik malah menyembur sekaligus.

python
location /events {
    proxy_pass http://127.0.0.1:8000;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 3600s;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
}

proxy_buffering off mematikan penahanan, proxy_read_timeout 3600s memastikan koneksi tidak diputus saat idle lama. Kalau kamu menjalankan uvicorn langsung tanpa proxy, header X-Accel-Buffering: no yang tadi sudah cukup.

Tanpa proxy_buffering off, streaming token LLM terlihat "ngeblok" lalu menyembur sekaligus. Gejala ini sering disalahartikan sebagai bug di kode, padahal sumbernya di lapisan proxy.

Langkah Berikutnya

Coba buka endpoint /events di dua tab browser sekaligus, dan lihat keduanya menerima pesan yang sama. Setelah lancar, eksperimen dengan retry: dan id: untuk menangani reconnect. Kalau kamu memakai htmx di frontend, ada ekstensi SSE bawaan yang bikin streaming ini jauh lebih ringkas — Belajar htmx: AJAX Tanpa Menulis JavaScript. Dari sana, tinggal ganti generatornya dengan feed data asli.

FAQ Singkat

SSE dan WebSocket, bedanya apa?

SSE satu arah dari server ke klien dan berjalan di atas HTTP biasa, sedangkan WebSocket dua arah lewat protokol sendiri. Untuk notifikasi, live log, dan streaming token, SSE lebih simpel dan punya reconnect otomatis.

Apakah SSE butuh server atau library khusus?

Tidak. Di sisi klien cukup objek EventSource bawaan browser. Di sisi server, FastAPI menyediakannya lewat StreamingResponse dengan media_type="text/event-stream".

Kenapa pesan saya keluar sekaligus, bukan satu per satu?

Biasanya buffering proxy. Matikan dengan proxy_buffering off di Nginx atau tambahkan header X-Accel-Buffering: no pada respons.