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.

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ứctrue nghĩ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ềnResulting Content-Type
stringtext/plain;charset=UTF-8
Blobtype của chính blob
FormDatamultipart/form-data; boundary=…
URLSearchParamsapplication/x-www-form-urlencoded;charset=UTF-8
ArrayBuffer / typed arraykhông có Content-Type

Đây là lý do beacon JSON dùng mẹo Blob: 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);

Lưu ý CORS: 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. 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.


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.

sendBeaconfetch(keepalive)sync XHR
Chặn unloadKhôngKhôngCó — đơ UI
MethodPOST onlyBất kỳAny
Header tuỳ chỉnhKhôngYes
Đọc responseKhôngYes
Trả vềbooleanPromise
Hạn mức kích thước~64KB~64KB (shared keepalive pool)
Hợp nhất choTelemetry đơn giản lúc thoátCầ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

unloadbeforeunload 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à 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);
}

Vì sao chống trùng: visibilitychange cả 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 sendBeacon trả 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ề: false nghĩa là chưa xếp hàng — hãy có fallback fetch(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ông GET, 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 fetch có 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ông unload/beforeunload.
  • Chống trùng bằng cờ sent.
  • Bọc JSON trong Blob đúng type.
  • 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

  1. Dựng hàng đợi track(name, props) gom event và flush qua sendBeacon lúc visibilitychange, có fallback fetch(keepalive).
  2. 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. Ép sendBeacon trả false bằng payload >64KB, và xác minh nhánh fallback chạy.
  4. 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 —