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.
The Problem Beacon Solves {Vấn đề Beacon giải quyết}
You want to send one last piece of data as the user leaves: an analytics event, a performance metric, “how long did this session last” {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”}. The naive approach is a fetch() in a beforeunload/unload handler — and it silently fails {Cách ngây thơ là fetch() trong handler beforeunload/unload — và nó âm thầm thất bại}.
When the page is being torn down, the browser kills in-flight async requests {Khi trang đang bị huỷ, browser giết các request async đang bay}. Developers used to “fix” this with a synchronous XMLHttpRequest, which blocks the main thread and freezes the UI during navigation — a terrible experience the platform now actively discourages {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() exists for exactly this {navigator.sendBeacon() sinh ra đúng cho việc này}: a fire-and-forget, non-blocking POST that the browser guarantees to attempt even after the page is gone {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}.
The API in 30 Seconds {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)
- It always sends a
POST{Luôn gửiPOST}. - It returns a boolean immediately —
truemeans queued, not delivered {Trả về boolean ngay lập tức —truenghĩa là đã xếp hàng, không phải đã giao}. - There is no response, no promise, no callback — you cannot read the server’s reply {Không response, không promise, không callback — bạn không đọc được phản hồi server}.
- The request runs in the background and outlives the document {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);
}
What data Can Be (and the Content-Type Trap) {data có thể là gì (và bẫy Content-Type)}
sendBeacon accepts the same body types as fetch {sendBeacon nhận cùng kiểu body như fetch}, and the Content-Type is inferred from the type you pass — you cannot set headers manually {và Content-Type được suy ra từ kiểu bạn truyền — bạn không set header thủ công được}:
| You pass {Bạn truyền} | Resulting Content-Type |
|---|---|
string | text/plain;charset=UTF-8 |
Blob | The blob’s own type {type của chính blob} |
FormData | multipart/form-data; boundary=… |
URLSearchParams | application/x-www-form-urlencoded;charset=UTF-8 |
ArrayBuffer / typed array | no Content-Type {không có Content-Type} |
This is why JSON beacons use the Blob trick {Đây là lý do beacon JSON dùng mẹo Blob}: wrap the string in a Blob whose type is application/json {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);
CORS nuance {Lưu ý CORS}: a
Blobtypedapplication/jsonis not a CORS “simple request”, so a cross-origin beacon triggers a preflight — which often cannot complete during unload {mộtBlobkiể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}. For cross-origin collectors, many teams sendtext/plain(a JSON string with a safelisted content type) and parse it server-side {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}.
Beacon vs fetch(keepalive) vs sync XHR {So sánh}
The modern alternative is fetch with the keepalive flag {Lựa chọn hiện đại khác là fetch với cờ keepalive}. It also survives unload, but trades simplicity for control {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 | |
|---|---|---|---|
| Blocks unload {Chặn unload} | No {Không} | No {Không} | Yes — freezes UI {Có — đơ UI} |
| Method {Method} | POST only | Any {Bất kỳ} | Any |
| Custom headers {Header tuỳ chỉnh} | No {Không} | Yes {Có} | Yes |
| Read response {Đọc response} | No {Không} | Yes {Có} | Yes |
| Returns {Trả về} | boolean | Promise | — |
| Size budget {Hạn mức kích thước} | ~64KB | ~64KB (shared keepalive pool) | — |
| Best for {Hợp nhất cho} | Simple telemetry on exit {Telemetry đơn giản lúc thoát} | Need headers/auth or response {Cần header/auth hoặc response} | Never {Đừng dùng} |
Rule of thumb {Quy tắc ngón tay cái}: reach for sendBeacon first; switch to fetch(keepalive) only when you need an Authorization header, a non-POST method, or to inspect the response {ư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 */ });
}
The #1 Mistake: Listening to the Wrong Event {Lỗi số 1: nghe sai event}
unload and beforeunload are unreliable, especially on mobile {unload và beforeunload không đáng tin, nhất là trên mobile}: when a user switches apps or the OS reclaims a backgrounded tab, those events often never fire {khi user đổi app hoặc OS thu hồi tab nền, các event đó thường không bao giờ chạy}. They also disable the back/forward cache (bfcache), hurting navigation performance {Chúng còn vô hiệu hoá bfcache, làm chậm điều hướng}.
The reliable signal is visibilitychange → hidden, with pagehide as a companion {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);
}
Why de-dupe {Vì sao chống trùng}:
visibilitychangeandpagehidecan both fire during a single exit, so guard with asentflag to avoid double-counting {cả hai có thể chạy trong một lần thoát, nên dùng cờsentđể tránh đếm trùng}. If the user returns and leaves again, reset the flag per session as needed {Nếu user quay lại rồi rời nữa, reset cờ theo phiên khi cần}.
Real-World Use Cases {Use case thực tế}
1. Analytics & product telemetry {Analytics & telemetry sản phẩm}
The canonical use {Use case kinh điển}: batch click/scroll/feature-usage events in memory and flush them on visibilitychange so you never lose the last interactions of a session {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. Real User Monitoring (Core Web Vitals) {Giám sát người dùng thực (Core Web Vitals)}
Metrics like LCP, CLS, and INP are only final when the page is hidden {Các chỉ số như LCP, CLS, INP chỉ chốt khi trang ẩn}. Libraries like web-vitals report on visibilitychange via beacon {Thư viện như web-vitals report lúc visibilitychange qua beacon}. See the deep dive {Xem bài chuyên sâu}: Core Web Vitals & INP playbook.
3. End-of-session & dwell time {Kết thúc phiên & thời gian ở lại}
Send sessionDuration, last route, and scroll depth as the user leaves — data that a normal fetch would drop {Gửi sessionDuration, route cuối, độ sâu scroll khi user rời — dữ liệu mà fetch thường sẽ rớt}.
4. Client-side error & crash logging {Log lỗi & crash phía client}
In a window.onerror / unhandledrejection handler, beacon the stack trace immediately — the page may be about to die {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. A/B test exposure & funnel events {Phơi nhiễm A/B test & sự kiện phễu}
Record “user saw variant B” or “reached checkout step 3” reliably, even if they bounce right after {Ghi “user thấy variant B” hay “tới bước checkout 3” đáng tin, kể cả khi họ thoát ngay sau}.
6. Autosave drafts on exit {Tự lưu nháp khi thoát}
For low-stakes drafts (a comment box, search filters), beacon the current text on visibilitychange as a safety net beside your normal debounced save {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. Media engagement heartbeats {Nhịp đo tương tác media}
Flush “watched 73% of the video” when the tab hides mid-playback {Flush “đã xem 73% video” khi tab ẩn giữa chừng}.
Limits & Gotchas {Giới hạn & cạm bẫy}
- Size cap (~64KB) {Trần kích thước (~64KB)}: large payloads make
sendBeaconreturnfalse. Keep beacons small; flush periodically instead of one giant blob on exit {payload lớn khiếnsendBeacontrảfalse. Giữ beacon nhỏ; flush định kỳ thay vì một blob khổng lồ lúc thoát}. - Always check the return value {Luôn kiểm tra giá trị trả về}:
falsemeans it was not queued — have afetch(keepalive)fallback {falsenghĩa là chưa xếp hàng — hãy có fallbackfetch(keepalive)}. - No response, ever {Không bao giờ có response}: do not use beacon when you need confirmation or returned data {đừng dùng beacon khi cần xác nhận hoặc dữ liệu trả về}.
POSTonly {ChỉPOST}: cannotGET,PUT, orDELETE{khôngGET,PUT,DELETEđược}.- Cookies are sent {Cookie được gửi}: beacons include credentials for same-site requests — relevant for auth and CSRF posture (see Cookies in the frontend) {beacon kèm credentials cho request same-site — liên quan tới auth và CSRF}.
- CORS still applies {CORS vẫn áp dụng}: cross-origin beacons need a CORS-safelisted content type to avoid an unfulfillable preflight {beacon cross-origin cần content type được safelist để tránh preflight không hoàn tất kịp}.
- Not for critical writes {Không dùng cho write quan trọng}: payments, order submits, anything that must succeed — use a confirmed
fetch{thanh toán, đặt hàng, bất cứ thứ gì phải thành công — dùngfetchcó xác nhận}.
Debugging Beacons {Debug beacon}
- DevTools → Network: beacons appear as a request to your endpoint (Chrome labels the Type as
ping). Enable “Preserve log” so the entry is not wiped by the navigation that triggered it {bật “Preserve log” để entry không bị xoá bởi chính lần điều hướng kích hoạt nó}. - Reproduce reliably {Tái hiện đáng tin}: trigger it by switching tabs (fires
visibilitychange) rather than only closing the tab {kích hoạt bằng cách đổi tab (chạyvisibilitychange) thay vì chỉ đóng tab}. - Inspect the body {Soi body}: check the request payload’s Content-Type matches what your server expects — the silent
text/plaindefault catches everyone once {kiểm tra Content-Type của payload khớp với server kỳ vọng — mặc địnhtext/plainâm thầm khiến ai cũng dính một lần}.
Checklist {Danh sách kiểm}
- Use
visibilitychange → hidden(+pagehide), notunload/beforeunload{Dùngvisibilitychange → hidden(+pagehide), khôngunload/beforeunload}. - De-dupe with a
sentflag {Chống trùng bằng cờsent}. - Wrap JSON in a
Blobwith the righttype{Bọc JSON trongBlobđúngtype}. - Check the boolean return; fall back to
fetch(keepalive){Kiểm tra boolean; fallbackfetch(keepalive)}. - Keep payloads under ~64KB; flush periodically {Giữ payload dưới ~64KB; flush định kỳ}.
- For cross-origin, use a safelisted content type {Cross-origin thì dùng content type được safelist}.
- Never beacon critical, must-succeed writes {Đừng beacon các write quan trọng phải thành công}.
Exercises {Bài tập}
- Build a tiny
track(name, props)queue that batches events and flushes viasendBeacononvisibilitychange, with afetch(keepalive)fallback {Dựng hàng đợitrack(name, props)gom event và flush quasendBeaconlúcvisibilitychange, có fallbackfetch(keepalive)}. - Send the same JSON two ways and inspect the Network tab: once as a raw string, once as a typed
Blob. Confirm the differingContent-Type{Gửi cùng JSON hai cách và soi tab Network: một lần chuỗi thô, một lầnBlobcó type. Xác nhậnContent-Typekhác nhau}. - Force
sendBeaconto returnfalseby sending a >64KB payload, and verify your fallback path runs {ÉpsendBeacontrảfalsebằng payload >64KB, và xác minh nhánh fallback chạy}. - Stretch {Nâng cao}: measure session duration and beacon it on exit, then handle the case where the user returns (tab visible again) and leaves a second time {Đ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}.
Solution sketch {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
}
});Wrap-up {Tổng kết}
The Beacon API is small but it removes a whole category of “our last events go missing” bugs {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”}. Remember three things {Nhớ ba điều}: send on visibilitychange, wrap JSON in a typed Blob, and always have a fetch(keepalive) fallback {gửi lúc visibilitychange, bọc JSON trong Blob có type, và luôn có fallback fetch(keepalive)}. For anything needing a response or guaranteed delivery, reach for a confirmed fetch instead {Với thứ cần response hay đảm bảo giao, hãy dùng fetch có xác nhận} — see robust data fetching.