The Beacon API — Sending Data That Survives Page Unload
A practical guide to navigator.sendBeacon: why it exists, how it differs from fetch keepalive, the Content-Type/Blob trick, CORS and size limits, and the real-world use cases — analytics, web vitals, error logging, autosave.
Vấn đề Beacon giải quyết
Bạn muốn gửi một mẩu dữ liệu cuối khi user rời đi: một analytics event, một chỉ số hiệu năng, “phiên này kéo dài bao lâu”. Cách ngây thơ là fetch() trong handler beforeunload/unload — và nó âm thầm thất bại.
Khi trang đang bị huỷ, browser giết các request async đang bay. Trước đây dev “chữa” bằng XMLHttpRequest đồng bộ, thứ chặn main thread và làm đơ UI khi điều hướng — một trải nghiệm tệ mà nền tảng giờ chủ động ngăn cản.
navigator.sendBeacon() sinh ra đúng cho việc này: một POST bắn-và-quên, không chặn, mà browser đảm bảo sẽ cố gửi kể cả sau khi trang biến mất.
API trong 30 giây
const ok = navigator.sendBeacon(url, data);
// ok === true → the browser queued the request for delivery
// ok === false → it refused (too large, or it could not queue)
- Luôn gửi
POST. - Trả về boolean ngay lập tức —
truenghĩa là đã xếp hàng, không phải đã giao. - Không response, không promise, không callback — bạn không đọc được phản hồi server.
- Request chạy nền và sống lâu hơn document.
// Minimal real example: flush a queue of events on the way out
function flush(events) {
const body = JSON.stringify({ events, sentAt: Date.now() });
const blob = new Blob([body], { type: 'application/json' });
navigator.sendBeacon('/api/collect', blob);
}
data có thể là gì (và bẫy Content-Type)
sendBeacon nhận cùng kiểu body như fetch, và Content-Type được suy ra từ kiểu bạn truyền — bạn không set header thủ công được:
| Bạn truyền | Resulting Content-Type |
|---|---|
string | text/plain;charset=UTF-8 |
Blob | type của chính blob |
FormData | multipart/form-data; boundary=… |
URLSearchParams | application/x-www-form-urlencoded;charset=UTF-8 |
ArrayBuffer / typed array | không có Content-Type |
Đây là lý do beacon JSON dùng mẹo Blob: bọc chuỗi trong Blob có type là application/json.
// ❌ Server receives Content-Type: text/plain — it may reject or misparse
navigator.sendBeacon('/api/collect', JSON.stringify(payload));
// ✅ Explicit JSON content type
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
navigator.sendBeacon('/api/collect', blob);
Lưu ý CORS: một
Blobkiểuapplication/jsonkhông phải “simple request” CORS, nên beacon cross-origin sẽ kích hoạt preflight — thứ thường không hoàn tất kịp lúc unload. Với collector khác origin, nhiều team gửitext/plain(chuỗi JSON với content type được safelist) rồi parse phía server.
So sánh
Lựa chọn hiện đại khác là fetch với cờ keepalive. Nó cũng sống sót qua unload, nhưng đánh đổi sự đơn giản lấy khả năng kiểm soát.
sendBeacon | fetch(keepalive) | sync XHR | |
|---|---|---|---|
| Chặn unload | Không | Không | Có — đơ UI |
| Method | POST only | Bất kỳ | Any |
| Header tuỳ chỉnh | Không | Có | Yes |
| Đọc response | Không | Có | Yes |
| Trả về | boolean | Promise | — |
| Hạn mức kích thước | ~64KB | ~64KB (shared keepalive pool) | — |
| Hợp nhất cho | Telemetry đơn giản lúc thoát | Cần header/auth hoặc response | Đừng dùng |
Quy tắc ngón tay cái: ưu tiên sendBeacon; chỉ chuyển sang fetch(keepalive) khi cần header Authorization, method khác POST, hoặc cần xem response.
function report(url, payload) {
const body = new Blob([JSON.stringify(payload)], { type: 'application/json' });
// Prefer beacon; fall back to keepalive fetch if it refuses (e.g. too large)
if (navigator.sendBeacon?.(url, body)) return;
fetch(url, { method: 'POST', body, keepalive: true, headers: { 'Content-Type': 'application/json' } })
.catch(() => { /* last-ditch: nothing else we can do on the way out */ });
}
Lỗi số 1: nghe sai event
unload và beforeunload không đáng tin, nhất là trên mobile: khi user đổi app hoặc OS thu hồi tab nền, các event đó thường không bao giờ chạy. Chúng còn vô hiệu hoá bfcache, làm chậm điều hướng.
Tín hiệu đáng tin là visibilitychange → hidden, kèm pagehide:
function sendOnExit(getPayload) {
let sent = false;
const fire = () => {
if (sent) return; // de-dupe: both events can fire
sent = true;
report('/api/collect', getPayload());
};
// Fires when the tab is hidden (app switch, tab change, navigation away)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') fire();
});
// Safety net for the actual page teardown / bfcache eviction
window.addEventListener('pagehide', fire);
}
Vì sao chống trùng:
visibilitychangecả hai có thể chạy trong một lần thoát, nên dùng cờsentđể tránh đếm trùng. Nếu user quay lại rồi rời nữa, reset cờ theo phiên khi cần.
Use case thực tế
1. Analytics & telemetry sản phẩm
Use case kinh điển: gom các event click/scroll/dùng-tính-năng trong bộ nhớ rồi flush lúc visibilitychange để không mất các tương tác cuối phiên.
const queue = [];
export const track = (name, props) => queue.push({ name, props, t: Date.now() });
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && queue.length) {
const blob = new Blob([JSON.stringify(queue.splice(0))], { type: 'application/json' });
navigator.sendBeacon('/api/events', blob);
}
});
2. Giám sát người dùng thực (Core Web Vitals)
Các chỉ số như LCP, CLS, INP chỉ chốt khi trang ẩn. Thư viện như web-vitals report lúc visibilitychange qua beacon. Xem bài chuyên sâu:
3. Kết thúc phiên & thời gian ở lại
Gửi sessionDuration, route cuối, độ sâu scroll khi user rời — dữ liệu mà fetch thường sẽ rớt.
4. Log lỗi & crash phía client
Trong handler window.onerror / unhandledrejection, beacon stack trace ngay — trang có thể sắp chết.
window.addEventListener('error', (e) => {
const blob = new Blob([JSON.stringify({ msg: e.message, stack: e.error?.stack, url: location.href })],
{ type: 'application/json' });
navigator.sendBeacon('/api/errors', blob);
});
5. Phơi nhiễm A/B test & sự kiện phễu
Ghi “user thấy variant B” hay “tới bước checkout 3” đáng tin, kể cả khi họ thoát ngay sau.
6. Tự lưu nháp khi thoát
Với nháp ít rủi ro (ô comment, bộ lọc), beacon text hiện tại lúc visibilitychange như lưới an toàn cạnh save debounce thường.
7. Nhịp đo tương tác media
Flush “đã xem 73% video” khi tab ẩn giữa chừng.
Giới hạn & cạm bẫy
- Trần kích thước (~64KB): payload lớn khiến
sendBeacontrảfalse. Giữ beacon nhỏ; flush định kỳ thay vì một blob khổng lồ lúc thoát. - Luôn kiểm tra giá trị trả về:
falsenghĩa là chưa xếp hàng — hãy có fallbackfetch(keepalive). - Không bao giờ có response: đừng dùng beacon khi cần xác nhận hoặc dữ liệu trả về.
- Chỉ
POST: khôngGET,PUT,DELETEđược. - Cookie được gửi: beacon kèm credentials cho request same-site — liên quan tới auth và CSRF.
- CORS vẫn áp dụng: beacon cross-origin cần content type được safelist để tránh preflight không hoàn tất kịp.
- Không dùng cho write quan trọng: thanh toán, đặt hàng, bất cứ thứ gì phải thành công — dùng
fetchcó xác nhận.
Debug beacon
- bật “Preserve log” để entry không bị xoá bởi chính lần điều hướng kích hoạt nó.
- Tái hiện đáng tin: kích hoạt bằng cách đổi tab (chạy
visibilitychange) thay vì chỉ đóng tab. - Soi body: kiểm tra Content-Type của payload khớp với server kỳ vọng — mặc định
text/plainâm thầm khiến ai cũng dính một lần.
Danh sách kiểm
- Dùng
visibilitychange → hidden(+pagehide), khôngunload/beforeunload. - Chống trùng bằng cờ
sent. - Bọc JSON trong
Blobđúngtype. - Kiểm tra boolean; fallback
fetch(keepalive). - Giữ payload dưới ~64KB; flush định kỳ.
- Cross-origin thì dùng content type được safelist.
- Đừng beacon các write quan trọng phải thành công.
Bài tập
- Dựng hàng đợi
track(name, props)gom event và flush quasendBeaconlúcvisibilitychange, có fallbackfetch(keepalive). - Gửi cùng JSON hai cách và soi tab Network: một lần chuỗi thô, một lần
Blobcó type. Xác nhậnContent-Typekhác nhau. - Ép
sendBeacontrảfalsebằng payload >64KB, và xác minh nhánh fallback chạy. - Nâng cao: Đo thời lượng phiên và beacon lúc thoát, rồi xử lý khi user quay lại (tab hiện lại) và rời lần hai.
Gợi ý lời giải
const queue = [];
const track = (name, props) => queue.push({ name, props, t: Date.now() });
function flush() {
if (!queue.length) return;
const batch = queue.splice(0);
const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
if (navigator.sendBeacon?.('/api/events', blob)) return;
// requeue + keepalive fallback so we do not lose the batch
fetch('/api/events', { method: 'POST', body: blob, keepalive: true })
.catch(() => queue.unshift(...batch));
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
window.addEventListener('pagehide', flush);
// (4) session duration that survives return visits
let start = Date.now();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
track('session_tick', { ms: Date.now() - start });
} else {
start = Date.now(); // user came back — restart the clock
}
});Tổng kết
Beacon API nhỏ nhưng xoá hẳn một nhóm bug “các event cuối của tụi mình bị mất”. Nhớ ba điều: gửi lúc visibilitychange, bọc JSON trong Blob có type, và luôn có fallback fetch(keepalive). Với thứ cần response hay đảm bảo giao, hãy dùng fetch có xác nhận —