SSE (Server Sent Events)讓 server 可以一直主動送資料給瀏覽器。它不是什麼特別的協定,就是一段普通的 HTTP 回應,裡面放純文字,每筆事件之間用一個空白行隔開。瀏覽器內建的 EventSource 會幫你把文字切成一筆一筆的事件,斷線還會自動重連;如果改用 fetch 自己讀,拿到的就只是沒切好的一段段文字,這些事都得自己做。
前置知識:Day 14 看過 Hono 如何把資料一段一段寫進 stream、client 逐塊讀出來。沒讀過也不影響,今天聊聊 EventSource 這個新名詞,當然看過 ReadableStream 體驗會更深刻。
學習路線:今天從一個 Hono SSE endpoint 出發。先看 SSE 長什麼樣、為什麼 chunk 不等於 event,再用 EventSource 完整接一次,最後拆掉它,看看 response.body 那條路差在哪。
標籤:ES2018 Async Generator SSE Async Iterable TransformStream backpressure AbortSignal
Day 14 翻 Hono 的串流 helper 時,看到 server 可以把資料一段一段寫進 stream,client 那端則逐塊讀出來。翻著翻著,就撞見了另一個名詞:SSE(Server-Sent Events) 😏——server 在同一條連線上持續推送事件,瀏覽器直接就能收。
但這兩件事其實不在同一層:stream 交給你的是 chunk,SSE 卻變成是「一筆有名稱、有 data 的事件」。所以問題來了——這個邊界,到底由誰負責定義?
Hono streamSSE() 從 server 持續送出事件,並用 EventSource 完整接收一次。EventSource 的四個實務陷阱,尤其是一次性回應被自動重連。response.body 這條路拿到的是什麼,以及為什麼收 SSE 不該自己重造。
HTTP 的預設節奏是一問一答:client 發 request,server 回 response,結束。但有些場景需要反過來——通知、進度回報、AI 逐字生成答案,這些都是 server 有話要說,而且不知道什麼時候說完。
SSE(Server-Sent Events)就是為此設計的:client 只發一次 request,server 把 response 一直開著,隨時往裡面寫新事件。
它沒有另外開一條協定,就跑在普通的 HTTP response 上,內容是純文字。一筆事件長這樣:
event: time-update
data: 2026-09-19T02:23:24.808Z
id: 0
← 這一行是空的,代表「這筆講完了」
規則只有幾條:每行是「欄位名 + 冒號 + 值」,data 放內容,event 給事件取名字,id 讓斷線重連時知道接到哪裡。
它不是排版留白,是協定規定的結束記號——而且不是空格,是一行零個字元的行。把跳脫字元寫出來就清楚了:
"event: time-update\ndata: 10:00\n\n"
↑↑
│└─ 空白行:這筆事件結束
└── data 那行自己的換行
結尾是連續兩個 \n:第一個結束 data: 那一行,第二個獨立成一行、內容為空,就是邊界。
之所以能拿它當記號,是因為 SSE 每行都是「欄位名 + 冒號 + 值」,空白行不可能出現在一筆事件內部,不會誤判。
反過來說也成立:沒補上空白行,事件就永遠不會送達。server 只寫了 data: hello\n 就停手的話,這筆資料會一直卡在 parser 的緩衝區裡。第三節會看到 Hono 的 writeSSE() 最後那個 + '\n\n',做的就是這件事。
這些都不是實作慣例,而是 WHATWG HTML Standard 的明文規定。規格在逐行處理規則裡列了 "If the line is empty (a blank line)" 這一條,對應的動作就是把事件送出去;而串流若在最後一個空行之前就結束,殘留的資料必須被丟棄,那筆不完整的事件不會被送達。
常有人拿它跟 WebSocket 比:
WebSocket 是雙向的、能傳二進位,但要另外交握、自己處理重連;SSE 只能 server 單向送文字,換來的是「就是一個 HTTP response」的簡單,瀏覽器還內建了自動重連。只需要 server 推、不需要 client 一直回話時,SSE 通常夠用。
ReadableStream 是什麼關係?一開始會發現怎麼有兩種接收 Stream 資料流的用法,也是我最容易打結的地方—— 因為 Day 14 看到的 ReadableStream 好像也在做 「資料陸續抵達」 這件事~
差別在於它們回答不同層次的問題。ReadableStream 是 Web API,描述程式如何讀取一段還在抵達的資料; SSE 比較像在 HTTP response 裡的文字約定,描述這些資料要如何組成一筆事件。
ReadableStream |
SSE | |
|---|---|---|
| 是什麼 | 通用的 Web 資料流介面 | HTTP response 上的文字事件格式 |
| consumer 拿到什麼 | chunk;網路 response 常是 Uint8Array |
event;有 type、data、lastEventId 等意義 |
| 邊界由誰決定 | 網路 response 的 chunk 由 runtime/傳輸層交付;不保證對應任何業務資料 | 以空白行結束一筆 event,例如 data: ...\n\n |
| 常見 consumer | getReader()、read()、for await...of |
EventSource 的 message 或自訂事件 listener |
| 能拿來做什麼 | 檔案、一般 response、NDJSON、SSE 等各種持續資料 | server 單向推送可命名事件 |
一個 SSE event 可能被切成多個 ReadableStream chunk;多個 event 也可能一起出現在同一個 chunk。

最容易混淆的地方在於:chunk 不等於 event。
把一筆 SSE event 想成一張便條紙:最後那個空白行(\n\n)代表「這張寫完了」。假設 server 連續寫出兩張便條:
event: tick
data: 1
← 空白行,第一張寫完了
event: tick
data: 2
← 空白行,第二張寫完了
但網路不像辦公室助理,會一張一張完整交到你手上;它比較像郵差,每次往信箱塞多少紙不固定。可能先送來這些:
chunk 1 "event: tick\ndata: 1\n\nevent: ti"
chunk 2 "ck\ndata: 2\n\n"
第一個 chunk 裡,第一筆 event 已經完整,可以交付;但第二筆只收到一半,得先留在緩衝區。等 chunk 2 到了,補齊 \n\n,才能交付第二筆。
數
\n的時候很容易看錯:data: 1\n\n裡的兩個\n,第一個只是結束data: 1那一行,第二個才是空白行。兩個換行字元加起來,畫面上只會看到一個空行,不是兩個。
也可能一次送來兩張:
chunk 1 "event: tick\ndata: 1\n\nevent: tick\ndata: 2\n\n"
這次同一個 chunk 裡有兩個 \n\n,所以可以一次交付兩筆 event。
所以 parser 的工作很單純:先把新 chunk 接到緩衝區後面;只要找到 \n\n,就切出一筆完整 event;剩下的文字繼續留著等下一個 chunk。
而且是湊齊一筆就交付一筆,不會等整條串流結束。上面那個例子裡,第一筆在 chunk 1 抵達的當下就送出去了,你的 listener 那時就被呼叫;第二筆要等 chunk 2 才補齊。AI 逐字回覆之所以能一個字一個字出現在畫面上,就是這個道理。
沒湊齊的殘料則不會半套交出去——如果連線在這時候斷掉,那半筆會直接被丟棄,不會有人收到不完整的事件。
writeSSE()呼叫兩次,只代表 server 寫了兩筆 SSE event;不代表 client 下一次讀取時一定剛好拿到兩筆。event 的邊界由 SSE 格式決定,chunk 的邊界則由傳輸層決定。
這個 parser 瀏覽器已經放在 EventSource 裡了。 用 EventSource 時,你直接收到完整 event;改用 fetch() 讀 response.body 時,才需要自己面對 chunk、緩衝區與切分。
整趟旅程長這樣:
server 想說的事 { event: "time-update", data: "10:00" }
↓ 用 writeSSE() 組成文字,結尾補上空白行
一段文字 "event: time-update\ndata: 10:00\n\n"
↓ 轉成位元組,塞進 HTTP response
網路上的 chunk chunk A、chunk B、chunk C…(怎麼切不一定)
↓ EventSource 累積、找空白行、切出來
一個完整的事件 { type: "time-update", data: "10:00" }
頭尾兩端長得一模一樣——server 想送什麼,client 就收到什麼。中間那三層是為了讓資料能過網路,繞的路。
這是今天真正的分岔點:
EventSource,三行程式,事件自動組好送到你手上。fetch 讀 response.body,拿到的是 chunk,累積和切分都得自己寫。
response.body那一層流的是還沒分好的位元組,EventSource那一層流的是已經分好的事件。
streamSSE() 持續送出事件Hono 的 streamSSE() 底層同樣建立 Web TransformStream。差別在於,它在一般 writer 之外提供 writeSSE():把一筆應用程式訊息轉成 event:、data:、id:、retry: 等行,最後補上 \n\n 作為 event 分隔。
以下是可放進 Hono Node.js 專案的最小 route。以 npm create hono@latest 建立 Node.js template 後,將核心 route 放入 src/index.ts,啟動開發伺服器,再用 curl -N http://localhost:3000/events 即可觀察連續事件:
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
const app = new Hono();
app.get('/events', (c) =>
streamSSE(c, async (stream) => {
let id = 0;
let active = true;
stream.onAbort(() => {
active = false;
console.log('client disconnected');
});
while (active && !stream.aborted) {
await stream.writeSSE({
event: 'time-update',
id: String(id++),
data: new Date().toISOString(),
});
await stream.sleep(1000);
}
}),
);
serve({ fetch: app.fetch, port: 3000 });
writeSSE() 負責把物件組成 SSE 文字,sleep() 則是每秒送一筆。callback 只要不結束,response 就一直開著。
onAbort() 是 client 斷線時的通知——關掉分頁、換頁、或呼叫 source.close() 都會觸發。範例用它把 active 設成 false,讓迴圈停下來,實際專案也可以在這裡清掉 timer 或訂閱。要注意它只代表「這條連線沒人在聽了」,不表示 server 其他工作也跟著取消。
writeSSE() 的實作幾乎全是字串處理:
// 簡化自 hono/src/helper/streaming/sse.ts
const sseData = [
message.event && `event: ${message.event}`,
dataLines,
message.id !== undefined && `id: ${message.id}`,
].filter(Boolean).join('\n') + '\n\n';
await this.write(sseData); // ← 字串組完之後,才寫進 stream
注意最後一行:SSE 的格式化發生在寫進管線之前。也就是說——
stream 那一層完全不知道 SSE 是什麼。
管線裡流的只是 bytes。\n\n 對它而言只是兩個換行字元,沒有任何理由順著它切——這就是第一節「chunk 不等於 event」的根本原因。
下一節換到 client,看瀏覽器怎麼把這些 bytes 變回事件。
EventSource 把事件收下來server 那邊一直在 push,client 這邊要有人接。瀏覽器內建的 EventSource 就是為 SSE 準備的,一個可以直接放進頁面的完整版本長這樣:
const output = document.querySelector('#output');
const status = document.querySelector('#status');
const source = new EventSource('/events');
// 1. 連上了
source.addEventListener('open', () => {
status.textContent = '連線中';
});
// 2. 依事件名稱分流——名稱就是 server 那邊 writeSSE({ event: ... }) 給的
source.addEventListener('time-update', (e) => {
output.textContent = e.data;
});
// 3. server 說結束了,就主動關掉(下一節會解釋為什麼非關不可)
source.addEventListener('done', () => {
status.textContent = '完成';
source.close();
});
// 4. 出事了。注意這裡拿不到 status code,只知道「斷了」
source.addEventListener('error', () => {
status.textContent = source.readyState === EventSource.CLOSED
? '已中斷'
: '重新連線中…';
});
// 5. 離開頁面時收拾乾淨
window.addEventListener('beforeunload', () => source.close());
幾個容易漏掉的點:
event: 的事件,會進到 message。所以 server 若只寫 data:,這裡要監聽的是 'message' 而不是自訂名稱。e.data 永遠是字串。送 JSON 的話,這端要自己 JSON.parse()。e.lastEventId 對應 server 的 id:;斷線重連時瀏覽器會用 Last-Event-ID 這個 header 把它送回去,server 就能從斷點接續。readyState 有三種:CONNECTING、OPEN、CLOSED,用來分辨「正在重連」和「真的結束了」。到這裡整個 SSE 串接就完成了。你沒有碰到任何 ReadableStream——瀏覽器把讀取、解碼、切事件、重連全部包掉了。
ReadableStream 呢?拆掉 EventSource 看看Day 14 讀 HTTP 串流用的是 response.body,一個 ReadableStream。同一個 /events,改用 fetch() 讀讀看:
const response = await fetch('/events');
for await (const chunk of response.body.pipeThrough(new TextDecoderStream())) {
console.log(JSON.stringify(chunk));
}
這段看起來很長,其實只做三件事:從 response 拿位元組 → 轉成文字 → 一段一段印出來。
| 寫法 | 在做什麼 |
|---|---|
response.body |
網路送來的原始資料;裡面是位元組,也就是一串數字,不是文字 |
new TextDecoderStream() |
把這串數字翻成 UTF-8 文字 |
.pipeThrough(...) |
讓資料先經過這個「翻譯器」,後面拿到的就會是文字 |
for await...of |
每有一段新文字抵達,就拿一段出來;還沒到就先等 |
JSON.stringify() 只是方便觀察:直接印文字時,\n 會真的換行;包起來後才會顯示成 \n,比較看得出 chunk 是在哪裡切開的。
那 TextDecoderStream 能省嗎?不行。因為網路切 chunk 時,連一個字都可能被切成兩半。
UTF-8 裡一個中文字通常有 3 個位元組;如果「你」只來了前兩個位元組,現在還不能翻成文字。
TextDecoderStream會先記住這半個字,等下一個 chunk 把最後一個位元組送來,再交出完整的「你」,簡單來說文字在電腦或網路傳輸存儲的單位是不一樣地~需要轉換。
這其實和 SSE parser 做的是同一件事:不完整的資料先留著,等下一段補齊。 差別只在它們拼的是不同東西:
| 層級 | 什麼被切開 | 誰負責接回去 |
|---|---|---|
| 位元組 | 一個 UTF-8 字元 | TextDecoderStream |
| 事件 | 一筆 SSE event | EventSource(或你自己) |
pipeThrough() 怎麼把多個轉接頭串成一條管線
輸出是這樣:
"event: time-update\ndata: 2026-09-19T02:23:24.808Z\nid: 0\n\n"
"event: time-update\ndata: 2026-09-19T02:23:25.810Z\nid: 1\n\n"
沒有 e.data,沒有事件名稱可以分流——你拿到的是一段一段的原始文字,event:、data: 只是字串的一部分。
而且不能假設「一個 chunk 就是一筆事件」。實測把 data 換成 64KB,第一個 chunk 有 65529 個字元卻一筆完整事件都沒有;把 sleep() 拿掉連發,50 筆事件又會擠進同一個 chunk。要拿到事件,就得自己累積文字、自己找 \n\n、自己把欄位拆成物件——還要處理斷線重連與 Last-Event-ID。
這就是兩者的分工:
ReadableStream(response.body) |
EventSource |
|
|---|---|---|
| 你拿到什麼 | chunk,一段還沒分好的文字或位元組 | event,type、data、lastEventId 都備妥 |
| 邊界誰決定 | 傳輸層,跟你的資料無關 | SSE 的空白行 |
| 誰負責解析 | 你 | 瀏覽器 |
| 斷線重連 | 自己寫 | 內建,會帶 Last-Event-ID 續傳 |
| 適用範圍 | 任何串流:檔案、NDJSON、自訂協定 | 只有 text/event-stream |
所以收 SSE 就用 EventSource,不要自己重造——尤其重連牽涉退避與續傳,手寫很容易漏。
會需要退回
fetch+response.body的,是那些EventSource做不到的場合:它只能發 GET、不能自訂 header、不能帶 body,因為要送Authorization: Bearer、要 POST 帶 prompt、要能中途取消,才只好自己讀response.body解析資料。
上面那段程式能跑,但放進真實專案還有幾個坑。
EventSource 會自己重連這是最常踩的。把一個「送完就結束」的 endpoint 交給它——例如 AI 回答完就關閉連線:
// server:送完三段文字就結束 callback,Hono 隨即自動 close
for (const word of ['Async', ' Generator', ' 很好用']) {
await stream.writeSSE({ event: 'delta', data: word });
await stream.sleep(200);
}
await stream.writeSSE({ event: 'done', data: '' });
client 實際跑出來是這樣:
delta: "Async"
delta: " Generator"
delta: " 很好用"
done
★ error 事件(EventSource 準備重連)
delta: "Async" ← 整段又來一次
delta: " Generator"
delta: " 很好用"
done
server 端也確實印出「第 1 次連線」「第 2 次連線」。
原因是 EventSource 的設計前提是長期存在的通知管道:連線關閉一律當成異常,隔一段時間就重連。但「一次性回答」關閉是正常結束——它分不出這兩種情況。
放著不管,使用者會看到答案重複,後端會被重複計費。解法就是前一節那行:
source.addEventListener('done', () => source.close());
server 說結束了,client 要自己關。 這是使用 EventSource 最重要的一條規矩。
close()const source = new EventSource('/events');
// 換頁、元件卸載、關閉分頁時
source.close();
忘了關的後果比一般連線更嚴重——它不只是佔著連線,而是會持續重連,即使畫面早就換掉了。
error 事件不帶 status code:
source.addEventListener('error', () => {
// 只知道「出事了」,不知道是 401、500,還是網路斷線
});
所以做不出「登入已過期,請重新登入」這種精確提示,只能顯示籠統的「連線中斷」。能分辨的只有 readyState:是還在重連,還是已經 CLOSED。
如果產品上非得區分不可,那就是前一節說的「EventSource 做不到」的場合之一。
AI 逐字回覆時一秒可能來幾十筆。每筆都改 DOM 會讓畫面卡住。累積起來,交給下一個影格一次更新:
let pending = '';
let scheduled = false;
source.addEventListener('delta', (e) => {
pending += e.data;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
output.textContent += pending;
pending = '';
scheduled = false;
});
});
這跟 SSE 其實無關,是「資料逐筆抵達」本身帶來的問題:資料來得比你畫得快。
今天只用了一個 /events。從 server 到畫面,是這樣一條路:
server
writeSSE({ event, data }) 組成文字,結尾補上空白行
↓
HTTP response(一直開著)
↓ 網路把它切成任意大小的 chunk
client
EventSource 累積、找空白行、切出事件、斷線自動重連
↓
addEventListener('time-update', …)
中間那段 chunk 的切法完全不可預期,但你不必管——EventSource 就是為了把這段包掉而存在的。
只有在它做不到的時候(要自訂 header、要 POST、要能中途取消),才需要退回 fetch 讀 response.body,自己面對那些 chunk。
今天的重點可以濃縮成三句:
event:、data:、id: 幾行,空白行代表一筆結束。沒有二進位、沒有交握,curl -N 就看得見。streamSSE() 讓 route handler 可以一直 writeSSE(),response 保持開著。EventSource 收——它把讀取、解碼、切事件、重連全包了。你只要 addEventListener,還有記得該關的時候 close()。至於 response.body 那條路:它給你的是還沒分好的 chunk,適用範圍更廣(任何串流都能讀),但 SSE 這件事上,瀏覽器已經幫你做完了,沒必要重造。
WHATWG HTML Standard:Server-sent events
SSE 的完整規格:欄位定義、空白行如何觸發送出事件、合法換行是 CRLF/LF/CR 三種,以及 retry:、Last-Event-ID 與重連規則。
MDN:EventSource 與 Using server-sent events
第三節那段程式的依據:open/message/error 事件、readyState、close(),以及建構子只接受 (url, { withCredentials })——也就是「不能自訂 header、不能帶 body」的原因。
Hono Streaming Helper 與 streamSSE() 原始碼
第二節 writeSSE() 的欄位順序、結尾的 + '\n\n' 與 onAbort() 實作,本文對照 Hono v4.13.8。
MDN:Using readable streams
第四節拆掉 EventSource 之後走的那條路:response.body 怎麼讀、chunk 怎麼來。