WebSocket SSE และ Polling ต่างกันอย่างไร ควรเลือกแบบไหน
Polling ส่ง request ใหม่ทุกรอบ SSE เปิด response ค้างให้ server ส่ง event ทางเดียว WebSocket upgrade แล้วส่ง frame ได้สองทาง พร้อมเรื่อง reconnect และ proxy
ปานกลาง9 นาทีในการอ่าน
POLLING a new request every 3 s GET /updates client ──────────────────────────────────────────── server ● ← 204 No Content ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ SSE one request, the response stays open GET /events Accept: text/event-stream client ──────────────────────────────────────────── server ● ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ WEBSOCKET one upgrade, then frames both ways GET /ws Upgrade: websocket client ──────────────────────────────────────────── server ● same data, three connection shapes
ในบทความนี้
ตอบสั้น ๆ ก่อนครับ ถ้าข้อมูลไหลจาก server มาที่ browser ทางเดียว เช่น สถานะงาน การแจ้งเตือน หรือค่าจาก sensor ให้เริ่มจาก SSE (Server-Sent Events) ถ้าทั้งสองฝั่งต้องส่งหากันบ่อย ๆ เช่น แชตหรือเอกสารที่แก้พร้อมกัน ให้ใช้ WebSocket ส่วน polling เหมาะเมื่อข้อมูลเปลี่ยนไม่บ่อยและยอมให้ช้าได้หลายวินาที หรือเมื่อโครงสร้างระหว่างทางไม่รองรับ connection ที่เปิดค้างนาน
สามแบบนี้ต่างกันที่หน้าตาของ connection บน wire และความต่างนั้นส่งผลถึง proxy, load balancer และ firewall ที่อยู่ระหว่างทางด้วย บทความนี้จะดูทีละแบบว่า request และ response จริงหน้าตาเป็นอย่างไร หลุดแล้วต่อใหม่อย่างไร แล้วจบด้วยสิ่งที่ต้องตั้งค่าที่ตัวกลางครับ
POLLING a new request every 3 s GET /updates client ──────────────────────────────────────────── server ● ← 204 No Content ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ SSE one request, the response stays open GET /events Accept: text/event-stream client ──────────────────────────────────────────── server ● ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ WEBSOCKET one upgrade, then frames both ways GET /ws Upgrade: websocket client ──────────────────────────────────────────── server ● same data, three connection shapes
Polling ทำงานอย่างไร
polling คือ client ส่ง HTTP request ไปถามเป็นระยะว่ามีอะไรใหม่ไหม RFC 6202 (เมษายน พ.ศ. 2554) แบ่งไว้สองแบบ
short polling client ส่ง request ทุกช่วงเวลาที่กำหนด ถ้าไม่มีข้อมูลใหม่ server ตอบกลับเป็น response ว่าง แล้ว client รอรอบถัดไป ปัญหาคือ resource ที่ใช้ทั้งฝั่ง server และ network ขึ้นกับว่ายอมให้ข้อมูลช้าได้แค่ไหน ถ้าต้องการให้ช้าไม่เกินไม่กี่วินาที ก็ต้องถี่ขึ้นจน server รับไม่ไหวได้
GET /api/updates?since=1041 HTTP/1.1
Host: app.example.com
HTTP/1.1 204 No Content
long polling server ไม่ตอบทันที แต่ถือ request ไว้จนกว่าจะมีข้อมูลใหม่หรือหมดเวลา เมื่อ client ได้ response ก็มักส่ง request ใหม่ทันที ผลคือ server มี request ค้างไว้รอตอบอยู่เกือบตลอด
RFC 6202 ระบุข้อเสียของ long polling ไว้หลายข้อ ข้อที่เจอบ่อยมีสามข้อ
- header ซ้ำทุกรอบ ทุก request และ response เป็น HTTP message เต็ม ๆ พร้อม header ครบชุด ถ้าข้อความเล็ก header อาจเป็นส่วนใหญ่ของข้อมูลที่ส่ง
- latency สูงสุดเกินสาม network transit หลังส่ง response แล้ว server ต้องรอ request ถัดไปก่อนจึงส่งข้อความใหม่ได้ ค่าเฉลี่ยจึงใกล้หนึ่ง transit แต่ค่าสูงสุดเกินสาม transit
- resource ค้างต่อ client ต้องถือทั้ง TCP connection และ HTTP request ไว้หนึ่งชุดต่อ client และบาง gateway, proxy และ server จำกัดจำนวน request ที่ค้างได้
ข้อดีของ polling คือมันเป็น HTTP ธรรมดาทุกประการ RFC 6202 บอกว่า proxy ส่วนใหญ่ทำงานกับ long polling ได้ดี เพราะ response ที่ส่งออกไปเป็น response ที่สมบูรณ์ทุกครั้ง
SSE ทำงานอย่างไร
SSE คือ client ส่ง GET ครั้งเดียว แล้ว server ตอบด้วย content type text/event-stream และไม่ปิด response แต่เขียน event ต่อท้ายไปเรื่อย ๆ ฝั่ง browser ใช้ interface EventSource ซึ่งกำหนดไว้ใน HTML Standard ของ WHATWG
const source = new EventSource("/api/events");
source.addEventListener("job", (e) => console.log(e.lastEventId, e.data));
response บน wire หน้าตาแบบนี้
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-store
: keep-alive
id: 1042
event: job
data: {"job": 7, "state": "running"}
id: 1043
event: job
data: {"job": 7, "state": "done"}
กฎของรูปแบบนี้มีไม่กี่ข้อ (WHATWG, 9.2.6)
| บรรทัด | ความหมาย |
|---|---|
data: |
ข้อมูลของ event ถ้ามีหลายบรรทัด จะต่อกันโดยคั่นด้วยขึ้นบรรทัดใหม่ |
event: |
ชื่อ event ถ้าไม่ใส่ จะเป็น event ชื่อ message |
id: |
ตั้ง last event ID สำหรับใช้ตอน reconnect |
retry: |
ตั้งเวลารอก่อน reconnect เป็นมิลลิวินาที |
บรรทัดที่ขึ้นต้นด้วย : |
comment ระบบไม่สนใจ |
| บรรทัดว่าง | ส่ง event ที่สะสมไว้ออกไป |
stream ต้องเข้ารหัสเป็น UTF-8 เสมอ จึงส่งข้อมูล binary ตรง ๆ ไม่ได้ และ SSE เป็นconnection ทางเดียว ถ้า client ต้องส่งอะไรกลับ ต้องส่งเป็น HTTP request แยก เช่น POST ธรรมดาครับ
WebSocket ทำงานอย่างไร
WebSocket เริ่มจาก HTTP request ที่ขอเปลี่ยน protocol แล้วหลังจากนั้นไม่ใช่ HTTP อีกต่อไป RFC 6455 (ธันวาคม พ.ศ. 2554) ยกตัวอย่าง handshake ไว้ดังนี้ (RFC 6455):
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
server คำนวณ Sec-WebSocket-Accept โดยเอาค่า key ต่อด้วย GUID คงที่ 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 แล้ว hash ด้วย SHA-1 และเข้ารหัส base64 เพื่อยืนยันว่า server เข้าใจ WebSocket จริง RFC เขียนไว้ชัดว่า WebSocket เป็นprotocol อิสระที่วิ่งบน TCP และเกี่ยวกับ HTTP แค่ตรงที่ handshake ถูกตีความเป็น Upgrade request
หลัง 101 ข้อมูลส่งเป็น frame แต่ละ frame มี opcode บอกชนิด เช่น 0x1 text, 0x2 binary, 0x8 close, 0x9 ping และ 0xA pong (RFC 6455, 5.2) และทั้งสองฝั่งส่งได้ตลอดเวลาบน TCP connection เดียว
มีรายละเอียดหนึ่งที่คนทำ network ควรรู้ client ต้อง mask ทุก frame ที่ส่งไป server ด้วยค่า 32 bit ที่สุ่มใหม่ทุก frame และ server ต้องไม่ mask frame ที่ส่งกลับ เหตุผลคือระหว่างที่ร่าง protocol มีการทดลองที่แสดงว่าทำ cache poisoning กับ proxy ที่ใช้งานจริงได้ ด้วยการส่งข้อมูลที่หน้าตาเป็น GET request ผ่าน connection ที่ upgrade แล้ว การ mask ทำให้ script ฝั่งผู้โจมตีกำหนดไม่ได้ว่าไบต์บน wire จะออกมาเป็นอะไร แต่ key อยู่ใน frame เอง ใครอ่าน frame ได้ก็ unmask ได้ การ mask จึงไม่ใช่การเข้ารหัส ความลับมาจาก TLS ของ wss:// ซึ่งใช้ port 443 เป็นค่าเริ่มต้น
ฝั่ง browser มีข้อจำกัดที่ควรรู้สองข้อ interface WebSocket ไม่รองรับ backpressure ถ้าข้อความมาเร็วกว่าที่แอปประมวลผลทัน ข้อความจะไปกองใน memory และหน้าที่มี WebSocket เปิดอยู่ browser อาจไม่เก็บลง bfcache จึงควรปิด connection เมื่อผู้ใช้ออกจากหน้า
หลุดแล้วต่อใหม่อย่างไร
SSE ต่อใหม่ให้เอง WebSocket ต้องเขียนเองครับ ข้อนี้เป็นความต่างที่ผมคิดว่าสำคัญที่สุดในงานจริง
SSE: ถ้า connection หลุด browser จะ reconnect เอง หลังรอตาม reconnection time ซึ่งค่าเริ่มต้นขึ้นกับ browser น่าจะอยู่ที่ไม่กี่วินาที และ server เปลี่ยนได้ด้วย retry: ตอนต่อใหม่ browser ส่ง header Last-Event-ID ที่มีค่า id: ล่าสุดที่ได้รับไปด้วย
GET /api/events HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 1043
server ที่เก็บ event ไว้สักช่วงจึงส่งต่อจาก 1044 ได้ โดยไม่ต้องส่งซ้ำทั้งหมด มีสองกรณีที่ browser หยุดต่อใหม่ คือ server ตอบ 204 No Content และกรณี response ไม่ใช่ 200 หรือ content type ไม่ใช่ text/event-stream ซึ่งถือว่า fail และจะไม่พยายามต่ออีก ข้อหลังนี้เจอได้เมื่อ proxy ตอบ 502 กลับมาครั้งเดียว stream ก็หยุดถาวร แอปควรดัก event error แล้วเปิด EventSource ใหม่เองเมื่อ readyState เป็น CLOSED
WebSocket: RFC 6455 ไม่ได้กำหนดกลไกแบบ Last-Event-ID ไว้ ถ้าต้องการส่งต่อจากข้อความล่าสุด ต้องออกแบบใน protocol ของแอปเอง เช่น ให้ client ส่งเลขข้อความล่าสุดเป็นข้อความแรกหลังต่อใหม่ เรื่องจังหวะการต่อใหม่ RFC กำหนดว่า client ควรใช้ backoff โดยครั้งแรกควรสุ่มรอ ซึ่งช่วงระหว่าง 0 ถึง 5 วินาทีเป็นค่าเริ่มต้นที่สมเหตุสมผล และครั้งต่อ ๆ ไปรอนานขึ้นเรื่อย ๆ เช่น truncated binary exponential backoff เหตุผลคือถ้า server ล่มแล้ว client ทุกตัวต่อใหม่ทันทีพร้อมกัน ผลจะไม่ต่างจาก denial-of-service
Polling: ไม่มี connection ให้หลุด request ถัดไปคือการต่อใหม่อยู่แล้ว แต่ควรส่ง cursor อย่าง since= ไปทุกครั้งเพื่อไม่ให้พลาดข้อมูลระหว่างรอบ
ข้อมูลไหลได้ทางไหนบ้าง
สรุปเทียบกันเป็นตารางครับ
| Polling | SSE | WebSocket | |
|---|---|---|---|
| ทิศทาง | client ถาม server ตอบ | server ไป client | ทั้งสองทาง |
| connection | request ใหม่ทุกรอบ | response เดียวที่เปิดค้าง | TCP connection เดียวหลัง upgrade |
| รูปแบบข้อมูล | อะไรก็ได้ที่ HTTP ส่งได้ | text UTF-8 | text หรือ binary frame |
| reconnect | ทุกรอบคือการต่อใหม่ | browser ทำเองพร้อม Last-Event-ID |
แอปเขียนเอง |
| ตัวกลางมองเห็น | HTTP ปกติ | HTTP response ที่ยาวมาก | HTTP แค่ตอน handshake |
proxy, load balancer และ firewall มองแต่ละแบบอย่างไร
ตัวกลางมองสามแบบนี้ต่างกันมาก polling เป็น HTTP ปกติ SSE เป็น HTTP response ที่ไม่จบ ส่วน WebSocket เป็น HTTP แค่ตอนเริ่ม แล้วกลายเป็น protocol อื่นบน TCP connection เดิม ปัญหาที่เจอส่วนใหญ่มาจากเรื่องต่อไปนี้ครับ
idle timeout
ตัวกลางส่วนใหญ่ตัด connection ที่เงียบนานเกินไป nginx เป็นตัวอย่างที่ชัด เอกสารบอกว่าถ้า backend ไม่ส่งข้อมูลภายใน 60 วินาที connection จะถูกปิด โดยค่าเริ่มต้น แก้ได้ด้วย proxy_read_timeout หรือให้ backend ส่ง ping frame เป็นระยะ
ทางแก้ของแต่ละแบบ
- WebSocket ส่ง ping frame ซึ่ง RFC บอกว่าใช้เป็น keepalive ได้ ฝั่งที่ได้ ping ต้องตอบ pong
- SSE ส่งบรรทัด comment เช่น
: keep-aliveHTML Standard แนะนำให้ส่งประมาณทุก 15 วินาที เพราะ legacy proxy บางตัวตัด connection หลัง timeout สั้น ๆ - long polling ตั้ง timeout ฝั่ง server ให้สั้นกว่าตัวกลาง RFC 6202 บอกว่า 30 วินาทีเป็นค่าที่ปลอดภัยกว่า แม้บางการทดลองจะใช้ได้ถึง 120 วินาที
buffering
SSE ใช้ได้ก็ต่อเมื่อแต่ละ event ไปถึง browser ทันที แต่ reverse proxy อาจ buffer response ไว้ก่อน nginx เปิด proxy_buffering เป็นค่าเริ่มต้น ผลคือ event ค้างอยู่ที่ proxy จนกว่า buffer จะเต็ม ปิดได้ทั้งใน config หรือให้ backend ส่ง header X-Accel-Buffering: no มากับ response ของ stream นั้น HTML Standard ก็เตือนเรื่องเดียวกันว่า chunking ที่ทำโดย layer ที่ไม่รู้เรื่องจังหวะเวลาอาจทำให้ SSE ไม่น่าเชื่อถือ
Upgrade header
Upgrade และ Connection เป็น hop-by-hop header ซึ่ง reverse proxy ไม่ส่งต่อให้ backend โดยอัตโนมัติ ถ้าไม่ตั้งค่า backend จะเห็นแค่ GET ธรรมดาและไม่ upgrade ใน nginx ต้องส่งต่อเอง:
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
location /api/events {
proxy_pass http://backend;
proxy_buffering off;
proxy_read_timeout 300s;
}
อีกเรื่องที่ตามมาคือ connection แบบ WebSocket หรือ SSE หนึ่งเส้นผูกกับ backend ตัวเดียวไปตลอดอายุ connection ถ้า load balancer ย้าย client ไปที่ backend อื่นได้แค่ตอนต่อใหม่ การ deploy หรือปิด backend หนึ่งตัวจึงทำให้ client ทั้งหมดของตัวนั้นต่อใหม่พร้อมกัน ซึ่งเป็นอีกเหตุผลที่ต้องมี backoff ครับ
จำนวน connection และ HTTP/2
ถ้าไม่ได้ใช้ HTTP/2 SSE เจอขีดจำกัดจำนวน connection ของ browser ที่ 6 connection ต่อ browser ต่อ domain รวมทุก tab ถ้าแต่ละ tab เปิด SSE หนึ่งเส้น tab ที่เจ็ดของเว็บเดียวกันจะต่อไม่ได้ บน HTTP/2 จำนวน stream พร้อมกันเป็นค่าที่สองฝั่งตกลงกัน และค่าเริ่มต้นคือ 100 HTML Standard ยังเสนอให้ใช้ EventSource ตัวเดียวร่วมกันผ่าน shared workerได้ด้วย
WebSocket แบบเดิมใช้ TCP connection ของตัวเอง ส่วน RFC 8441 (กันยายน พ.ศ. 2561) กำหนดวิธีรัน WebSocket บน stream เดียวของ HTTP/2 connection ผ่าน extended CONNECT ซึ่ง server ต้องประกาศ SETTINGS_ENABLE_CONNECT_PROTOCOL ก่อน
firewall และการตรวจ traffic
ฝั่ง security มีสองเรื่องที่ผมเช็กเสมอ
- อุปกรณ์ที่ตรวจ HTTP เห็นอะไร polling และ SSE เป็น HTTP ตลอดทาง policy ที่อิง URL, method หรือ header ใช้ได้ตามปกติ ส่วน WebSocket หลัง
101เป็น frame ของ protocol อื่น อุปกรณ์ที่อ่านแค่ HTTP จะเห็นแค่ handshake และถ้าเป็นwss://ทั้งหมดอยู่ใน TLS ต้อง decrypt ก่อนถึงจะเห็นแม้แต่ handshake - ตรวจ
Originที่ handshake script บนเว็บไหนก็ได้ที่ browser เปิดอยู่สามารถเปิด WebSocket ไปหา server ของเราได้ RFC จึงกำหนดว่า server ที่ไม่ได้ตั้งใจรับ input จากทุกเว็บควรตรวจว่าOriginเป็นค่าที่คาดไว้ และตอบ403 Forbiddenถ้าไม่ใช่
ควรเลือกแบบไหน
ไล่คำถามตามลำดับนี้ครับ
- client ต้องส่งข้อมูลถี่ ๆ ระหว่างที่รับข้อมูลอยู่ด้วยหรือไม่ ถ้าใช่ (แชต, เกม, แก้เอกสารพร้อมกัน) ใช้ WebSocket และเตรียมเขียน reconnect, backoff และการส่งต่อจากข้อความล่าสุดเอง
- ข้อมูลไหลจาก server ทางเดียวและเป็น text หรือไม่ ถ้าใช่ ใช้ SSE ได้ reconnect และ
Last-Event-IDมาฟรี และยังเป็น HTTP ที่ตัวกลางเข้าใจ อย่าลืมปิด buffering และส่ง comment keep-alive - ข้อมูลเปลี่ยนนาน ๆ ครั้ง หรือ client หลายตัวอยู่หลัง proxy ที่คุมไม่ได้หรือไม่ ถ้าใช่ polling ง่ายที่สุด ทดสอบง่าย และ cache กับ log ได้แบบ HTTP ทั่วไป
- ระหว่างทางมี proxy, load balancer หรือ firewall อะไรบ้าง เช็ก idle timeout, buffering และการส่งต่อ
Upgradeของทุกตัวก่อนเลือก ทั้งสามแบบทำงานได้ที่ปลายทางทั้งสองฝั่ง แต่ตัวกลางตัวเดียวที่ตั้งค่าไม่ตรงก็ทำให้ connection หลุดหรือ event ค้างได้
ไม่ว่าเลือกแบบไหน ให้ข้อความทุกชิ้นมีเลขลำดับ แล้ว client ส่งเลขล่าสุดที่ได้ไปทุกครั้งที่ต่อใหม่หรือถามใหม่ สามแบบนี้หลุดได้ทั้งหมด และเลขลำดับคือสิ่งที่ทำให้ไม่พลาดข้อมูลครับ
แหล่งอ้างอิง
- RFC 6455: The WebSocket Protocol (December 2011)
- RFC 6202: Known Issues and Best Practices for the Use of Long Polling and Streaming in Bidirectional HTTP (April 2011)
- RFC 8441: Bootstrapping WebSockets with HTTP/2 (September 2018)
- WHATWG HTML Standard: 9.2 Server-sent events
- MDN: Using server-sent events
- MDN: WebSocket API (WebSockets)
- nginx: WebSocket proxying
- nginx: Module ngx_http_proxy_module
อ่านต่อ
บทความที่เกี่ยวข้อง
การพัฒนาซอฟต์แวร์
RPC กับ REST แตกต่างกันอย่างไร และควรเลือกใช้แบบไหน
RPC เรียก procedure บน server ด้วยชื่อและพารามิเตอร์ ส่วน REST ส่งคำขอไปที่ resource ผ่าน interface กลางของ HTTP เทียบทีละประเด็นพร้อมตัวอย่าง gRPC และ JSON-RPC อิงจาก Fielding, RFC 9110 และ spec ต้นทาง
เริ่มต้น10 นาที
- API
- RPC
- REST
- gRPC
- JSON-RPC
- HTTP
การพัฒนาซอฟต์แวร์
แอปที่ใช้งานได้แม้ไม่มีเน็ตทำงานอย่างไร
แอปแบบ offline-first อ่านและเขียนข้อมูลในเครื่องก่อน แล้วค่อย sync ผ่านคิว อธิบายทีละขั้นตั้งแต่กดบันทึก การ retry จนถึงการเลือกข้อมูลเมื่อแก้ชนกัน
ปานกลาง9 นาที
- offline-first
- sync
- IndexedDB
- Service Worker
- WorkManager
- Flutter
- HTTP
การพัฒนาซอฟต์แวร์
HTTP/1.1, HTTP/2 และ HTTP/3 ต่างกันอย่างไร
ทั้งสามเวอร์ชันใช้ semantics เดียวกันตาม RFC 9110 แต่ส่งข้อมูลต่างกัน HTTP/1.1 เป็น text บน TCP, HTTP/2 เป็น binary frame บน TCP, HTTP/3 วิ่งบน QUIC ผ่าน UDP
ปานกลาง9 นาที
- HTTP
- HTTP/2
- HTTP/3
- QUIC
- TCP
- TLS
- Firewalls