Server-Sent Events di Next.js: Lebih Sederhana dari WebSocket

Foto oleh dpstyles™ on flickr
Server-Sent Events, atau SSE, adalah standar browser untuk mengalirkan respons berumur panjang dari server ke klien melalui HTTP biasa memakai API EventSource. Berbeda dengan WebSocket, SSE hanya satu arah dan tidak meningkatkan koneksi, sehingga lebih sederhana dijalankan. WebSocket menambah saluran dua arah penuh yang sepadan dengan kerumitannya hanya ketika klien juga perlu mendorong data terus-menerus.
Tulis route handler di App Router yang mengembalikan Response yang membungkus ReadableStream. Antrekan frame teks UTF-8 memakai TextEncoder dan setel tipe konten ke text/event-stream dengan header no-cache. Gunakan runtime Node.js dan paksa rute menjadi dinamis agar koneksi tetap terbuka dan tidak pernah tercache.
Pilih SSE ketika data mengalir sebagian besar satu arah, dari server ke klien. Dasbor langsung, progress bar pekerjaan, notifikasi, dan jawaban AI teraliran semuanya cocok dengan bentuk ini. Pilih WebSocket hanya ketika kedua sisi perlu mengirim data terus-menerus, seperti gim multipemain atau editor kolaboratif.
Ya. Browser menunggu interval retry lalu membuka kembali koneksi sendiri tanpa kode klien apa pun. Jika frame Anda menyertakan field id, browser mengirimnya kembali dalam header Last-Event-ID sehingga server dapat melanjutkan dari event terakhir yang diterima klien. Untuk menghentikan koneksi ulang, pancarkan event done dan panggil close pada EventSource.
Bisa, dan ini pemakaian paling populer saat ini. Iterasikan potongan teraliran dari penyedia model seperti Groq atau OpenAI dan antrekan setiap token sebagai frame data dalam ReadableStream. Klien mendengarkan dengan EventSource dan menambahkan token untuk efek mesin ketik. Nonaktifkan buffering proksi dan gzip agar token langsung terkirim alih-alih tiba sekaligus.

Foto oleh dpstyles™ on flickr
Setiap permintaan fitur real-time seolah datang dengan asumsi yang sama: kita akan butuh WebSocket. Notifikasi langsung, progress bar untuk pekerjaan panjang, asisten AI yang mengetik jawabannya kata demi kata — refleksnya adalah meraih socket dua arah, server khusus, dan pustaka koneksi ulang. Padahal sebagian besar fitur ini hanya mengirim data satu arah, dari server ke browser. Untuk tugas itu, Server-Sent Events jauh lebih sederhana, dan di Next.js ia pas ke dalam route handler yang sudah Anda kuasai.
Server-Sent Events, atau SSE, adalah standar browser untuk mengalirkan respons berumur panjang melalui HTTP biasa. Klien membuka koneksi dengan API bawaan EventSource, server menjaga respons tetap terbuka dan menulis frame teks saat ada kejadian, dan browser menyambung ulang sendiri jika koneksi terputus. Tidak ada peningkatan protokol, tidak ada port terpisah, dan tidak ada dependensi tambahan. Artikel ini membahas kapan SSE mengalahkan WebSocket, cara kerja format datanya, cara membangun route handler streaming di Next.js App Router, dan pola penting untuk koneksi ulang serta streaming token LLM.
WebSocket layak dengan kerumitannya ketika kedua sisi terus berbicara — gim multipemain, editor kolaboratif, obrolan tempat indikator mengetik beterbangan dua arah. SSE adalah alat yang lebih baik begitu lalu lintasnya sebagian besar satu arah, dari server ke klien. Itu mencakup banyak hal yang sebenarnya dibangun oleh tim:
Dalam semua kasus ini browser jarang perlu membalas apa pun di tengah aliran; sebuah POST biasa atau permintaan baru sudah cukup untuk perintah sesekali. Memilih SSE di sini menghapus satu kelas infrastruktur penuh — Anda tetap memakai HTTP biasa, cookie autentikasi yang sudah ada, dan load balancer yang sudah ada, serta melewati pusing soal sticky-session dan header upgrade yang dibawa WebSocket.
SSE nyaris memalukan karena sederhananya di level data. Server merespons dengan tipe konten text/event-stream lalu menulis teks UTF-8 dalam frame kecil yang dipisahkan baris kosong. Setiap frame tersusun dari beberapa field opsional, satu per baris:
Baris yang diawali titik dua adalah komentar dan diabaikan; komentar berkala membuat keep-alive murah yang mencegah proksi menganggur menutup koneksi. Karena semuanya hanya teks di atas HTTP, Anda dapat memantau aliran di terminal dengan curl dan membaca persis apa yang dilihat browser.
Di App Router, endpoint SSE hanyalah route handler yang mengembalikan Response yang membungkus sebuah ReadableStream. Anda mengantrekan frame teks terenkode saat event terjadi dan menutup controller ketika aliran selesai. Dua baris konfigurasi segmen penting: setel runtime Node.js agar koneksi dapat tetap terbuka, dan paksa rute menjadi dinamis agar Next.js tidak pernah mencoba menyimpannya di cache atau merendernya secara statis.
// app/api/stream/route.ts — an SSE endpoint in the App Router
export const runtime = "nodejs" // keep the connection open
export const dynamic = "force-dynamic" // never cache the stream
export async function GET(req: Request) {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown) => {
// one SSE frame: event line + data line + blank line
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
)
}
// suggest a client reconnect delay, then push updates
controller.enqueue(encoder.encode("retry: 3000\n\n"))
for (let i = 1; i <= 5; i++) {
send("tick", { n: i, at: Date.now() })
await new Promise((r) => setTimeout(r, 1000))
}
// stop the browser from auto-reconnecting when we are done
send("done", { ok: true })
controller.close()
},
})
// abort the loop if the client disconnects
req.signal.addEventListener("abort", () => {})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // disable proxy buffering (nginx)
},
})
}Header respons melakukan pekerjaan sesungguhnya. Tipe konten text/event-stream memberi tahu browser untuk memperlakukannya sebagai event stream; no-cache dan no-transform mencegah perantara melakukan buffering; dan X-Accel-Buffering yang disetel ke no menonaktifkan buffering respons nginx sehingga frame langsung terkirim alih-alih tiba dalam satu tumpukan di akhir. Membaca sinyal abort dari permintaan memungkinkan Anda mendeteksi ketika klien terputus dan menghentikan pekerjaan mahal — penting, karena jika tidak, tab browser yang tertutup dapat membiarkan sebuah perulangan tetap berjalan di server.
Kedua teknologi ini sebenarnya bukan pesaing, melainkan alat untuk bentuk masalah yang berbeda. Tabel ini menyusun kompromi yang biasanya menentukan pilihan:
| Aspek | Server-Sent Events | WebSocket |
|---|---|---|
| Arah | Satu arah, server ke klien | Dua arah penuh, kedua sisi |
| Transport | HTTP biasa, tanpa upgrade | Peningkatan TCP ke protokol ws |
| Koneksi ulang | Otomatis, bawaan browser | Manual, Anda tulis sendiri |
| Muatan | Hanya teks UTF-8 | Frame teks dan biner |
| Biaya penyiapan | Sebuah route handler dan EventSource | Server socket dan pustaka klien |
Satu catatan historis: di atas HTTP/1.1 browser hanya mengizinkan sekitar enam koneksi bersamaan per domain, dan setiap EventSource yang terbuka masuk hitungan itu di seluruh tab. HTTP/2 memultipleks banyak aliran melalui satu koneksi dan secara efektif menghapus batas itu, jadi menyajikan aplikasi Anda melalui HTTP/2 — yang dilakukan sebagian besar host modern secara bawaan — membuatnya bukan lagi masalah.
Fitur yang membuat SSE terasa tangguh secara gratis adalah koneksi ulang otomatis. Jika koneksi terputus, browser menunggu interval retry lalu membuka permintaan baru sendiri. Jika frame Anda menyertakan field id, browser mengirim nilai terakhir yang dilihatnya dalam header permintaan Last-Event-ID, dan server yang ditulis dengan baik membaca header itu untuk memutar ulang hanya bagian yang terlewat oleh klien. Di sisi klien Anda jarang menyentuh semua ini; Anda memasang listener dan membiarkan EventSource mengelola siklus hidupnya.
// Client: EventSource reconnects automatically on drop
const es = new EventSource("/api/stream")
es.addEventListener("tick", (e) => {
const payload = JSON.parse(e.data)
console.log("tick", payload.n)
})
es.addEventListener("done", () => es.close()) // no reconnect wanted
es.onerror = () => {
// readyState === 0 (CONNECTING) means it is retrying;
// the browser resends the Last-Event-ID header so the
// server can resume from the last id it emitted.
if (es.readyState === EventSource.CLOSED) console.log("closed for good")
}Satu hal yang harus Anda tangani secara sengaja adalah menghentikan. EventSource memperlakukan koneksi yang tertutup sebagai sinyal untuk mencoba lagi, jadi jika Anda sekadar mengakhiri respons, browser akan menyambung ulang dalam perulangan. Pancarkan event done eksplisit dan panggil close di klien, atau biarkan aliran terbuka sampai klien berpindah halaman. Memeriksa readyState di penangan error memberi tahu apakah browser sedang mencoba lagi, yang tampak sebagai keadaan CONNECTING, atau benar-benar menyerah, yang tampak sebagai CLOSED.
Kirim baris komentar seperti satu titik dua setiap 15 sampai 30 detik sebagai detak jantung. Koneksi menganggur sering dimatikan oleh load balancer dan reverse proxy setelah batas waktu tetap, dan komentar berkala kecil menjaga aliran tetap hidup tanpa mengotori data Anda atau memicu event klien apa pun.
Pola yang membuat SSE populer lagi adalah obrolan AI. Ketika model bahasa menghasilkan balasan, ia memproduksi token satu per satu, dan pengguna berharap melihat jawaban muncul alih-alih menatap pemutar loading. Dokumentasi Next.js menyebut streaming sebagai pendamping umum untuk API LLM, dan SSE adalah transport alaminya: iterasikan potongan teraliran dari model dan antrekan setiap token sebagai frame data. SDK penyedia — Groq, OpenAI, atau Vercel AI SDK — memberi Anda sebuah async iterable, dan Anda meneruskan setiap delta langsung ke dalam ReadableStream.
// app/api/chat/route.ts — stream LLM tokens as SSE frames
export async function POST(req: Request) {
const { prompt } = await req.json()
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const completion = await groq.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages: [{ role: "user", content: prompt }],
stream: true,
})
for await (const chunk of completion) {
const token = chunk.choices[0]?.delta?.content ?? ""
if (token) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(token)}\n\n`))
}
}
controller.enqueue(encoder.encode("event: done\ndata: end\n\n"))
controller.close()
},
})
return new Response(stream, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
})
}Di sisi klien listener EventSource yang sama menambahkan setiap token ke pesan yang terlihat, memberi efek mesin ketik yang familiar dengan hampir tanpa kode. Karena ini HTTP biasa, ia bekerja melalui middleware autentikasi dan pembatasan laju yang sudah ada, dan jika pengguna memuat ulang di tengah jawaban browser menyambung ulang otomatis. Khusus untuk obrolan, ingat untuk menutup aliran pada event done agar jawaban yang selesai tidak memicu koneksi ulang tanpa henti.
Buffering adalah alasan nomor satu sebuah aliran SSE tampak menggantung lalu menumpahkan semuanya sekaligus. Kompresi dan buffering proksi adalah pelaku biasa: gzip dan nginx dengan senang menahan frame Anda sampai buffer penuh. Kirim Cache-Control no-transform, setel X-Accel-Buffering ke no untuk nginx, dan hindari membungkus respons dalam middleware kompresi agar setiap frame terkirim persis saat Anda mengantrekannya.
SSE itu sederhana, tetapi ada beberapa sudut tajam yang menjebak tim di percobaan pertama. Ingat hal-hal ini:
Sebelum Anda mendirikan server WebSocket, tanyakan ke arah mana data sebenarnya mengalir. Jika jawabannya sebagian besar server ke klien — dasbor, progress bar, notifikasi, jawaban AI teraliran — Server-Sent Events memberi Anda pengalaman langsung yang sama di atas HTTP biasa, dengan koneksi ulang otomatis yang diserahkan browser kepada Anda dan endpoint yang hanyalah satu route handler Next.js lagi. Raih WebSocket ketika Anda benar-benar butuh saluran dua arah; raih SSE, yang merupakan pilihan lebih sederhana jauh lebih sering daripada yang disarankan refleks bawaan, untuk selebihnya.