jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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ửi POST}.
  • It returns a boolean immediatelytrue means queued, not delivered {Trả về boolean ngay lập tứctrue nghĩ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
stringtext/plain;charset=UTF-8
BlobThe blob’s own type {type của chính blob}
FormDatamultipart/form-data; boundary=…
URLSearchParamsapplication/x-www-form-urlencoded;charset=UTF-8
ArrayBuffer / typed arrayno 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 Blobtypeapplication/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 Blob typed application/json is not a CORS “simple request”, so a cross-origin beacon triggers a preflight — which often cannot complete during unload {một Blob kiểu application/json khô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 send text/plain (a JSON string with a safelisted content type) and parse it server-side {Với collector khác origin, nhiều team gửi text/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}.

sendBeaconfetch(keepalive)sync XHR
Blocks unload {Chặn unload}No {Không}No {Không}Yes — freezes UI {Có — đơ UI}
Method {Method}POST onlyAny {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ề}booleanPromise
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 {unloadbeforeunload 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 visibilitychangehidden, with pagehide as a companion {Tín hiệu đáng tin là visibilitychangehidden, 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}: visibilitychange and pagehide can both fire during a single exit, so guard with a sent flag 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 sendBeacon return false. Keep beacons small; flush periodically instead of one giant blob on exit {payload lớn khiến sendBeacon trả 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ề}: false means it was not queued — have a fetch(keepalive) fallback {false nghĩa là chưa xếp hàng — hãy có fallback fetch(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ề}.
  • POST only {Chỉ POST}: cannot GET, PUT, or DELETE {không GET, 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ùng fetch có 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ạy visibilitychange) 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/plain default catches everyone once {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}.

Checklist {Danh sách kiểm}

  • Use visibilitychange → hidden (+ pagehide), not unload/beforeunload {Dùng visibilitychange → hidden (+ pagehide), không unload/beforeunload}.
  • De-dupe with a sent flag {Chống trùng bằng cờ sent}.
  • Wrap JSON in a Blob with the right type {Bọc JSON trong Blob đúng type}.
  • Check the boolean return; fall back to fetch(keepalive) {Kiểm tra boolean; fallback fetch(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}

  1. Build a tiny track(name, props) queue that batches events and flushes via sendBeacon on visibilitychange, with a fetch(keepalive) fallback {Dựng hàng đợi track(name, props) gom event và flush qua sendBeacon lúc visibilitychange, có fallback fetch(keepalive)}.
  2. Send the same JSON two ways and inspect the Network tab: once as a raw string, once as a typed Blob. Confirm the differing Content-Type {Gửi cùng JSON hai cách và soi tab Network: một lần chuỗi thô, một lần Blob có type. Xác nhận Content-Type khác nhau}.
  3. Force sendBeacon to return false by sending a >64KB payload, and verify your fallback path runs {Ép sendBeacon trả false bằng payload >64KB, và xác minh nhánh fallback chạy}.
  4. 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.