Tanpa streaming, pengguna menunggu 5–15 detik melihat layar kosong. Dengan streaming, kata pertama muncul dalam 0,5 detik dan pengalaman terasa jauh lebih cepat — padahal total waktunya sama.
Bentuk datanya
Server mengirim potongan teks dengan format Server-Sent Events:
data: {"choices":[{"delta":{"content":"Halo"}}]}
data: {"choices":[{"delta":{"content":" dunia"}}]}
data: [DONE]Tiga hal yang wajib diingat:
- Satu potongan belum tentu satu baris utuh — bisa terpotong di tengah JSON.
- Antar potongan dipisahkan
\n\n, tapi ada juga keep-alive berupa baris kosong. - Baris
[DONE]menandakan selesai, bukan JSON.
Membaca stream di browser
export async function streamChat(messages, onToken) {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages, stream: true }),
});
if (!res.ok || !res.body) {
throw new Error("Gagal: " + res.status + " " + (await res.text()));
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// proses per baris lengkap; sisa yang belum lengkap disimpan di buffer
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (payload === "[DONE]") return;
try {
const json = JSON.parse(payload);
const text = json.choices?.[0]?.delta?.content;
if (text) onToken(text);
} catch {
// potongan JSON belum utuh → biarkan, akan disusul di iterasi berikut
}
}
}
}Perhatikan decoder.decode(value, { stream: true }). Tanpa stream: true, karakter multi-byte (huruf beraksen, emoji, aksara non-Latin) bisa rusak di batas potongan.
Kesalahan yang membuat teks dobel
1. Menambahkan teks ke state lalu merender seluruh riwayat. Kalau state-nya array dan kamu push potongan, tapi render memakai join dari sumber lain, teks muncul dua kali. Simpan satu string yang di-append, atau satu array yang dirender sekali.
2. Memproses `buffer` yang belum lengkap. Memanggil JSON.parse pada potongan setengah jadi memang melempar error — dan kalau kamu mengabaikannya lalu mengulang dari buffer penuh, satu baris bisa diproses dua kali. Solusinya seperti kode di atas: simpan sisa, proses hanya baris lengkap.
3. Lupa menangani `abort`. Pengguna menutup tab atau menekan "stop", tapi request tetap jalan dan tetap memakan kuota. Pakai AbortController:
const ac = new AbortController();
fetch("/api/chat", { signal: ac.signal, /* … */ });
// tombol stop:
ac.abort();Proxy di backend (jangan panggil provider dari browser)
Key tidak boleh ada di frontend. Alurnya: browser → backend-mu → provider. Backend meneruskan stream apa adanya:
// app/api/chat/route.js (Next.js App Router)
export async function POST(req) {
const body = await req.json();
const upstream = await fetch(process.env.AI_BASE_URL + "/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.AI_API_KEY}`,
},
body: JSON.stringify({ ...body, stream: true }),
});
return new Response(upstream.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}no-transform penting: sebagian proxy/CDN mengompres ulang dan menahan buffer, sehingga streaming terasa tidak jalan.
Ringkas
- SSE = baris
data: …dipisah baris kosong, diakhiri[DONE]. - Selalu buffer sisa baris; parse hanya yang lengkap.
- Pakai
TextDecoderdenganstream: true. - Sediakan tombol stop dengan
AbortControllersupaya kuota tidak terbuang. - Jangan pernah menaruh API key di browser.
