Học từ Facebook #5: Đừng tin navigator.onLine, hãy dùng heartbeat
navigator.onLine không biết bạn có ra được Internet. Cách Facebook phát hiện mất mạng bằng tỉ lệ lỗi request và heartbeat có backoff, viết lại bằng TypeScript.
navigator.onLine và hai event online / offline có vẻ là tất cả những gì cần để hiện banner "Bạn đang offline". Thực tế:
navigator.onLine === truechỉ nghĩa là máy có kết nối tới một mạng nào đó (Wi-Fi, LAN). Wi-Fi quán cà phê chưa đăng nhập captive portal, router mất Internet, VPN treo… đều báotrue.- Khi mất mạng thật, request của app lỗi trước khi trình duyệt kịp bắn
offline. Có lúc event này không bao giờ tới.
Tip
Tóm tắt
navigator.onLine === falseđáng tin,=== truethì không: nó không biết bạn có tới được server hay không.- Facebook nghe chính traffic của app: khi số request thành công không còn gấp đôi số request lỗi trong 5 giây, họ gọi một endpoint heartbeat nhỏ.
- Heartbeat có response (bất kể status) là online; lỗi thì thử lại với backoff lũy thừa có trần.
- Một response thật luôn thắng heartbeat; mọi vòng ping bị huỷ phải được đánh "phiên bản" để kết quả về muộn không ghi đè trạng thái mới.
Facebook giải quyết bằng NetworkStatusImpl + NetworkHeartbeat (thư mục NetworkStatus/ trong facebook-codebase).
#Bên trong trình duyệt: navigator.onLine thực sự đo cái gì?
Spec HTML để định nghĩa giá trị này cho trình duyệt tự quyết. Trên thực tế:
| Trình duyệt | onLine === false khi… |
|---|---|
| Chrome / Edge / Safari | máy không còn kết nối tới mạng cục bộ nào (rút cáp, tắt Wi-Fi, chế độ máy bay) |
| Firefox | ngày trước chỉ phản ánh chế độ "Work Offline" của trình duyệt; bản mới hơn theo trạng thái mạng của hệ điều hành |
Không trình duyệt nào kiểm tra "có ra được Internet không". Những trường hợp sau vẫn báo true:
- Captive portal: Wi-Fi khách sạn, sân bay bắt đăng nhập. Máy đã có IP, nhưng mọi request bị chặn lại hoặc chuyển hướng tới trang đăng nhập.
- Router mất kết nối WAN: máy nối tốt với router, router không nối được ra ngoài.
- DNS hỏng, VPN treo, firewall công ty chặn domain của bạn.
- Mạng 2G chập chờn: kết nối vẫn còn, nhưng mỗi gói tin mất hàng chục giây.
Điều ngược lại thì đáng tin hơn: onLine === false gần như chắc chắn là offline thật. Cho nên event offline là tín hiệu để báo offline ngay, còn event online chỉ là tín hiệu để kiểm tra lại.
#Facebook làm thế nào?
1. Lắng nghe request của chính app. Lớp request gọi reportError() khi lỗi mạng và reportSuccess() khi có response. Module giữ hai danh sách mốc thời gian (performanceNow()) trong cửa sổ 5 giây (k = 5e3).
2. Chỉ nghi ngờ khi tỉ lệ lỗi cao. Một request lỗi lẻ tẻ là chuyện thường. Hàm điều kiện v() lọc bỏ mốc cũ hơn 5 giây rồi trả về successes.length / errors.length < 2. Heartbeat chỉ bắt đầu khi số lần thành công không còn gấp đôi số lần lỗi.
3. Heartbeat. Gọi GET /nw/ (XHeartbeatController) bằng một XMLHttpRequest cùng origin (getSameOriginTransport). Hễ có onload, tức là có response với bất kỳ status code nào, thì coi như mạng đã thông. onerror hoặc ontimeout thì hẹn thử lại.
4. Backoff của Facebook tăng nhanh hơn bạn nghĩ. Code bắt đầu với i = 100, mỗi lần lỗi tăng k rồi tính i = min(i * 2^k, 6400). Đi từng bước:
| Lần lỗi | k |
Chờ trước lần thử kế | Delay mới |
|---|---|---|---|
| 1 | 1 | 100ms | 100 × 2¹ = 200 |
| 2 | 2 | 200ms | 200 × 2² = 800 |
| 3 | 3 | 800ms | 800 × 2³ = 6400 |
| 4+ | … | 6400ms | giữ ở trần 6400 |
Delay nhân với 2^k chứ không nhân 2, nên chỉ sau 3 lần đã chạm trần. Mạng chập chờn vài trăm ms có rất ít cơ hội được thử lại dày, rồi nhanh chóng chuyển sang ping thưa.
5. Một vòng heartbeat tại một thời điểm. isHeartbeatPending() (có một XHR đang bay hay không) chặn việc chạy song song nhiều vòng. Khi heartbeat đang chạy, hàm đổi trạng thái u() bỏ qua mọi thay đổi. Hệ quả là trong bản gốc, heartbeat lỗi không tự chuyển trạng thái sang offline. Offline chủ yếu đến từ event của trình duyệt; heartbeat dùng để xác nhận khi nào mạng thông trở lại.
6. Hai công tắc tắt từ xa. killswitch("DISABLE_HEARTBEAT_POLLING") tắt polling theo config server. Ngoài ra, nếu response của heartbeat có status 204, cùng cờ đó được bật và các lần heartbeat sau bị tắt. Có lẽ server dùng 204 như tín hiệu "đừng ping nữa" (suy đoán từ code). Một thiết kế đáng học: khi endpoint heartbeat gặp sự cố hoặc quá tải, server tự ra lệnh cho hàng triệu client ngừng ping mà không cần deploy lại frontend.
7. Log khi đổi trạng thái. Mỗi lần chuyển, module ghi FBLogger("NetworkStatus").warn("Network switched to offline"), nên đội vận hành biết người dùng mất mạng thường xuyên tới đâu.
Họ còn có CometNetworkStatusToast để hiện toast khi mất mạng (dùng toaster ở bài #4).
#Viết lại
API của bản viết lại chỉ có một giá trị boolean online. Bên trong, nó đi qua bốn tình huống: đang online yên ổn, đang online nhưng có một vòng heartbeat chạy để kiểm tra, đã offline và heartbeat đang thử lại theo backoff, và offline do hệ điều hành báo (lúc này không ping gì cả):
stateDiagram-v2 [*] --> Online Online --> OnlineChecking: "tỉ lệ lỗi cao / event online" OnlineChecking --> Online: "heartbeat có response" OnlineChecking --> OfflineRetrying: "heartbeat lỗi" OfflineRetrying --> OfflineRetrying: "lỗi, chờ backoff rồi thử lại" OfflineRetrying --> Online: "heartbeat có response" OnlineChecking --> Online: "request thật có response" OfflineRetrying --> Online: "request thật có response" Online --> OsOffline: "event offline" OnlineChecking --> OsOffline: "event offline" OfflineRetrying --> OsOffline: "event offline" OsOffline --> OfflineRetrying: "event online, heartbeat xác nhận" OsOffline --> Online: "request thật có response"
OnlineChecking vẫn trả về online = true, hai trạng thái còn lại trả về false. Nếu UI của bạn cần hiện "Đang kiểm tra kết nối…", hãy mở rộng API thành ba giá trị.
#Đi qua code
Heartbeat và vòng thử lại:
src/lib/networkStatus.ts
// navigator.onLine = false thì gần như chắc chắn mất mạng (không có card mạng nào kết nối):
// ping lúc này chỉ tốn pin. Ngược lại `true` thì chưa chắc, nên mới cần heartbeat.
const osSaysOffline = () => typeof navigator !== 'undefined' && navigator.onLine === false;
async function heartbeatOnce() {
try {
// Có response (kể cả 404/500) nghĩa là đã tới được server -> online.
// AbortSignal.timeout: huỷ request nếu treo quá `timeout` ms (mạng 2G có thể treo cả phút).
await fetchImpl(heartbeatUrl, { cache: 'no-store', signal: AbortSignal.timeout(timeout) });
return true;
} catch {
return false;
}
}
async function runHeartbeat(run: number) {
const ok = await heartbeatOnce();
if (run !== heartbeatRun) return; // vòng này đã bị huỷ trong lúc chờ
if (ok) {
heartbeatPending = false;
attempt = 0;
onHeartbeat?.({ type: 'heartbeat-ok' });
setOnline(true);
return;
}
setOnline(false);
if (osSaysOffline()) {
// Ngừng vòng ping; sự kiện `online` của trình duyệt sẽ khởi động lại heartbeat.
pauseHeartbeat();
return;
}
// 100ms, 200ms, 400ms ... tối đa maxDelay: ping dày lúc đầu (mạng hay chập chờn
// vài trăm ms) rồi thưa dần để không spam khi mất mạng lâu.
// (Facebook tăng nhanh hơn: 100 → 200 → 800 → 6400, xem bài viết.)
const retryIn = Math.min(initialDelay * 2 ** attempt, maxDelay);
attempt++;
onHeartbeat?.({ type: 'heartbeat-fail', attempt, retryIn });
retryTimer = setTimeout(() => runHeartbeat(run), retryIn);
}
ts
fetch()chỉ reject khi lỗi mạng (DNS, TCP, TLS, CORS…). Status 404 hay 500 vẫn resolve, nên "có response là online".fetch()không có timeout mặc định: trên mạng 2G request có thể treo vài phút.AbortSignal.timeout(timeout)biến "treo" thành "lỗi" để vòng backoff tiếp tục chạy.cache: 'no-store'buộc request đi qua mạng, không lấy bản cache HTTP.runHeartbeat(run)nhận số "lượt" của vòng heartbeat. Sauawait, việc đầu tiên là sorun !== heartbeatRun: vòng đã bị huỷ trong lúc chờ thì bỏ qua kết quả.- Lỗi thì
setOnline(false). Nếu lúc này hệ điều hành đã báo offline (navigator.onLine === false) thì ngừng ping và chờ eventonline: ping khi chắc chắn mất mạng chỉ tốn pin.
Huỷ, tạm dừng và khởi động một vòng:
src/lib/networkStatus.ts
function cancelHeartbeat() {
heartbeatRun++;
heartbeatPending = false;
attempt = 0;
clearTimeout(retryTimer);
}
function pauseHeartbeat() {
cancelHeartbeat();
onHeartbeat?.({ type: 'heartbeat-paused' });
}
function startHeartbeat() {
if (heartbeatPending) return; // đang ping rồi thì thôi
if (osSaysOffline()) return; // chờ sự kiện `online`
heartbeatPending = true;
onHeartbeat?.({ type: 'heartbeat-start' });
void runHeartbeat(heartbeatRun);
}
const onBrowserOnline = () => startHeartbeat(); // "online" có thể sai -> xác nhận lại
const onBrowserOffline = () => {
// Đây là tín hiệu tin được: báo offline ngay, dừng mọi vòng ping đang chạy.
setOnline(false);
if (heartbeatPending) pauseHeartbeat();
};
ts
startHeartbeat không làm gì nếu đã có vòng đang chạy, nên mọi tín hiệu nghi ngờ dồn về một vòng duy nhất. Event offline báo offline ngay và dừng vòng ping đang chạy.
Tín hiệu từ request của app:
src/lib/networkStatus.ts
/** Gọi khi một request của app lỗi mạng. */
reportError() {
errors.push(performance.now());
prune();
// Thỉnh thoảng lỗi một request là bình thường. Chỉ nghi ngờ mất mạng khi
// số lần thành công không còn gấp đôi số lần lỗi trong `windowMs` gần nhất.
if (successes.length < errors.length * 2) startHeartbeat();
},
/** Gọi khi một request của app có response. */
reportSuccess() {
successes.push(performance.now());
prune();
// Một response thật là bằng chứng mạnh hơn mọi heartbeat: huỷ vòng ping đang chạy
// (có thể đang chờ tới 6.4s) và báo online ngay.
if (heartbeatPending) cancelHeartbeat();
setOnline(true);
},
ts
reportError áp dụng đúng điều kiện của Facebook. reportSuccess là nơi bản viết lại khác bản gốc nhiều nhất: một response thật từ server là bằng chứng mạnh hơn mọi heartbeat, nên nó huỷ vòng ping đang chạy (cancelHeartbeat tăng heartbeatRun, xoá timer, reset attempt) và setOnline(true) ngay lập tức. Không có bước này, người dùng có thể nhìn banner "offline" thêm tới 6,4 giây (chờ lần heartbeat kế tiếp) trong khi app đang tải dữ liệu bình thường.
Khác bản gốc:
- Heartbeat lỗi chuyển sang offline (Facebook thì không, xem mục 5 ở trên).
- Đường backoff là nhân đôi thông thường (100, 200, 400, …, 6400) cho dễ dự đoán; muốn giống Facebook thì nhân với
2 ** attemptthay vì 2. - Tạm dừng ping khi
navigator.onLine === false, và một response thật huỷ vòng heartbeat.
Xem toàn bộ src/lib/networkStatus.ts
src/lib/networkStatus.ts
import { useSyncExternalStore } from 'react';
/**
* Trạng thái mạng "đáng tin" hơn navigator.onLine (ý tưởng từ NetworkStatusImpl + NetworkHeartbeat).
*
* - navigator.onLine chỉ biết máy có kết nối tới router hay không, không biết có ra được
* Internet / tới server của mình hay không.
* - Khi nghi ngờ (sự kiện offline, request lỗi nhiều), ta gọi một endpoint heartbeat nhỏ.
* Lỗi thì thử lại với exponential backoff, tới khi thành công thì báo online.
*/
export type HeartbeatEvent =
| { type: 'heartbeat-start' }
| { type: 'heartbeat-ok' }
| { type: 'heartbeat-fail'; attempt: number; retryIn: number }
/** hệ điều hành báo offline (navigator.onLine = false): ngừng ping, chờ sự kiện `online` */
| { type: 'heartbeat-paused' };
export interface NetworkStatusOptions {
heartbeatUrl: string;
fetchImpl?: typeof fetch;
initialDelay?: number;
maxDelay?: number;
timeout?: number;
/** cửa sổ thời gian để tính tỉ lệ lỗi */
windowMs?: number;
onHeartbeat?: (event: HeartbeatEvent) => void;
}
export function createNetworkStatus({
heartbeatUrl,
fetchImpl = (...args) => fetch(...args),
initialDelay = 100,
maxDelay = 6400,
timeout = 5000,
windowMs = 5000,
onHeartbeat,
}: NetworkStatusOptions) {
let online = typeof navigator === 'undefined' ? true : navigator.onLine;
const listeners = new Set<() => void>();
let heartbeatPending = false;
// Tăng mỗi khi một vòng heartbeat bị huỷ. Heartbeat đang bay về sau khi đã bị huỷ
// sẽ thấy `run !== heartbeatRun` và bỏ qua kết quả của nó.
let heartbeatRun = 0;
let attempt = 0;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let errors: number[] = [];
let successes: number[] = [];
const setOnline = (next: boolean) => {
if (online === next) return;
online = next;
listeners.forEach((listener) => listener());
};
const prune = () => {
const since = performance.now() - windowMs;
errors = errors.filter((t) => t > since);
successes = successes.filter((t) => t > since);
};
// navigator.onLine = false thì gần như chắc chắn mất mạng (không có card mạng nào kết nối):
// ping lúc này chỉ tốn pin. Ngược lại `true` thì chưa chắc, nên mới cần heartbeat.
const osSaysOffline = () => typeof navigator !== 'undefined' && navigator.onLine === false;
async function heartbeatOnce() {
try {
// Có response (kể cả 404/500) nghĩa là đã tới được server -> online.
// AbortSignal.timeout: huỷ request nếu treo quá `timeout` ms (mạng 2G có thể treo cả phút).
await fetchImpl(heartbeatUrl, { cache: 'no-store', signal: AbortSignal.timeout(timeout) });
return true;
} catch {
return false;
}
}
async function runHeartbeat(run: number) {
const ok = await heartbeatOnce();
if (run !== heartbeatRun) return; // vòng này đã bị huỷ trong lúc chờ
if (ok) {
heartbeatPending = false;
attempt = 0;
onHeartbeat?.({ type: 'heartbeat-ok' });
setOnline(true);
return;
}
setOnline(false);
if (osSaysOffline()) {
// Ngừng vòng ping; sự kiện `online` của trình duyệt sẽ khởi động lại heartbeat.
pauseHeartbeat();
return;
}
// 100ms, 200ms, 400ms ... tối đa maxDelay: ping dày lúc đầu (mạng hay chập chờn
// vài trăm ms) rồi thưa dần để không spam khi mất mạng lâu.
// (Facebook tăng nhanh hơn: 100 → 200 → 800 → 6400, xem bài viết.)
const retryIn = Math.min(initialDelay * 2 ** attempt, maxDelay);
attempt++;
onHeartbeat?.({ type: 'heartbeat-fail', attempt, retryIn });
retryTimer = setTimeout(() => runHeartbeat(run), retryIn);
}
function cancelHeartbeat() {
heartbeatRun++;
heartbeatPending = false;
attempt = 0;
clearTimeout(retryTimer);
}
function pauseHeartbeat() {
cancelHeartbeat();
onHeartbeat?.({ type: 'heartbeat-paused' });
}
function startHeartbeat() {
if (heartbeatPending) return; // đang ping rồi thì thôi
if (osSaysOffline()) return; // chờ sự kiện `online`
heartbeatPending = true;
onHeartbeat?.({ type: 'heartbeat-start' });
void runHeartbeat(heartbeatRun);
}
const onBrowserOnline = () => startHeartbeat(); // "online" có thể sai -> xác nhận lại
const onBrowserOffline = () => {
// Đây là tín hiệu tin được: báo offline ngay, dừng mọi vòng ping đang chạy.
setOnline(false);
if (heartbeatPending) pauseHeartbeat();
};
// An toàn khi import ở server (SSR): chỉ gắn listener khi có window.
if (typeof window !== 'undefined') {
window.addEventListener('online', onBrowserOnline);
window.addEventListener('offline', onBrowserOffline);
}
return {
isOnline: () => online,
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/** Gọi khi một request của app lỗi mạng. */
reportError() {
errors.push(performance.now());
prune();
// Thỉnh thoảng lỗi một request là bình thường. Chỉ nghi ngờ mất mạng khi
// số lần thành công không còn gấp đôi số lần lỗi trong `windowMs` gần nhất.
if (successes.length < errors.length * 2) startHeartbeat();
},
/** Gọi khi một request của app có response. */
reportSuccess() {
successes.push(performance.now());
prune();
// Một response thật là bằng chứng mạnh hơn mọi heartbeat: huỷ vòng ping đang chạy
// (có thể đang chờ tới 6.4s) và báo online ngay.
if (heartbeatPending) cancelHeartbeat();
setOnline(true);
},
destroy() {
cancelHeartbeat();
if (typeof window === 'undefined') return;
window.removeEventListener('online', onBrowserOnline);
window.removeEventListener('offline', onBrowserOffline);
},
};
}
export type NetworkStatus = ReturnType<typeof createNetworkStatus>;
/** Bọc fetch để tự báo thành công/lỗi cho NetworkStatus. */
export function trackedFetch(status: NetworkStatus, fetchImpl: typeof fetch = fetch): typeof fetch {
return async (...args) => {
try {
const response = await fetchImpl(...args);
status.reportSuccess();
return response;
} catch (error) {
// AbortError là do mình huỷ, không phải lỗi mạng
if (!(error instanceof DOMException && error.name === 'AbortError')) status.reportError();
throw error;
}
};
}
export function useOnline(status: NetworkStatus) {
// server không biết trạng thái mạng của client -> mặc định online
return useSyncExternalStore(status.subscribe, status.isOnline, () => true);
}
ts
#Race condition mà heartbeatRun chặn
Đây là kịch bản thật, xảy ra khi mạng vừa chập chờn xong:
sequenceDiagram participant App participant NS as NetworkStatus participant S as Server App->>NS: reportError NS->>S: heartbeat lượt 0 Note over S: mạng chậm, request treo App->>S: GET /api/feed S-->>App: 200 OK App->>NS: reportSuccess NS->>NS: huỷ lượt 0, heartbeatRun = 1, online S--xNS: heartbeat lượt 0 timeout sau 5s NS->>NS: run 0 khác 1, bỏ qua
Không có bộ đếm lượt, heartbeat lượt 0 về muộn sẽ gọi setOnline(false) và hẹn retry. Kết quả là app nhảy về offline dù vừa tải dữ liệu thành công, rồi vài giây sau lại nhảy về online. Banner nhấp nháy, và tệ hơn, có hai vòng heartbeat chạy song song.
Đây là pattern chung cho mọi tác vụ async có thể bị huỷ: gắn "phiên bản" vào lúc bắt đầu, so sánh lúc kết thúc. Bạn sẽ gặp lại nó ở search-as-you-type (bỏ kết quả của từ khoá cũ), ở useEffect fetch dữ liệu (cờ ignore trong docs React), ở optimistic update.
#Vì sao "có response là online"?
Một response HTTP, kể cả 500, chứng minh cả chuỗi đều thông: DNS phân giải được, TCP kết nối được, TLS bắt tay được. Đó là đúng câu hỏi "mạng có thông tới server của tôi không". Server trả 500 là chuyện server lỗi, cần xử lý riêng (retry, hiện thông báo lỗi), không nên trộn với "bạn đang offline".
Với HTTPS còn một lợi ích nữa: captive portal tự động bị lộ. Portal không có chứng chỉ TLS cho domain của bạn, nên request HTTPS bị chặn ở bước bắt tay và fetch reject. Heartbeat lỗi, đúng như mong muốn. Nếu heartbeat dùng HTTP thường, portal có thể trả về trang đăng nhập với status 200 và bạn tưởng là đang online.
#Backoff và jitter
Công thức của bản viết lại là backoff lũy thừa có trần:
delay(n) = min(cap, base × 2^n) base = 100ms, cap = 6400ms
= 100, 200, 400, 800, 1600, 3200, 6400, 6400, …
text
Mất mạng 1 phút thì tốn khoảng 16 request heartbeat: 7 request đầu nằm trong 6,3 giây đầu tiên (lần đầu cộng 6 lần thử lại), sau đó mỗi 6,4 giây một lần. Không có backoff (ping mỗi 100ms) thì con số là 600.
Vấn đề xuất hiện khi hàng triệu client cùng mất mạng một lúc, chẳng hạn khi server hoặc CDN sập. Tất cả cùng bắt đầu backoff tại một thời điểm, nên tất cả cùng ping tại 100ms, 300ms, 700ms… Server vừa sống lại liền hứng một loạt "đợt sóng" request đồng bộ (thundering herd). Jitter (thêm ngẫu nhiên) làm các client lệch pha nhau. Ba biến thể phổ biến, được phân tích kỹ trong bài "Exponential Backoff And Jitter" trên AWS Architecture Blog:
| Kiểu | Công thức | Đặc điểm |
|---|---|---|
| Full jitter | random(0, delay) |
trải đều nhất, tổng số request ít nhất |
| Equal jitter | delay/2 + random(0, delay/2) |
luôn chờ ít nhất một nửa, ít khi retry quá sớm |
| Decorrelated | min(cap, random(base, prev × 3)) |
phụ thuộc lần trước, không cần đếm attempt |
Thêm full jitter vào code chỉ cần một dòng:
const retryIn = Math.random() * Math.min(initialDelay * 2 ** attempt, maxDelay);
ts
Demo không dùng jitter để log dễ đọc. Ở production, hãy dùng.
#Tại sao không làm cách khác?
Sao không ping định kỳ, mỗi 30 giây một lần? Tốn request khi mọi thứ bình thường (mà phần lớn thời gian là bình thường), và chậm phát hiện khi mất mạng (trung bình 15 giây). Lắng nghe chính traffic của app thì miễn phí và phát hiện ngay.
Sao không coi một request lỗi là offline luôn? Một request lỗi có thể vì nhiều lý do không liên quan tới mạng: CORS sai, server đóng kết nối, user chuyển trang làm request bị huỷ. Với bản viết lại, một lỗi lẻ khi chưa có request thành công nào trong 5 giây vẫn khởi động heartbeat để xác nhận, nhưng bản thân nó không đổi trạng thái. Chỉ khi heartbeat cũng lỗi thì mới chuyển sang offline.
Sao heartbeat là endpoint riêng, không gọi lại API vừa lỗi? API vừa lỗi có thể nặng (tìm kiếm, upload) hoặc có side effect (POST). Endpoint heartbeat nhẹ, idempotent, không cần auth, và luôn trả lời nhanh.
Sao bản viết lại cho heartbeat lỗi chuyển sang offline, khác Facebook? Bản gốc phụ thuộc nhiều vào event offline của trình duyệt. Như phân tích ở trên, event này không bắn trong các trường hợp captive portal, router mất WAN… Chủ động chuyển offline khi heartbeat lỗi giúp phát hiện được cả những trường hợp đó. Đổi lại, ta phải xử lý race condition kỹ hơn, tức là bộ đếm heartbeatRun.
#Edge case và cạm bẫy
- CORS cũng làm
fetchreject vớiTypeError. NếutrackedFetchbọc request tới domain khác mà cấu hình CORS sai, mỗi request sẽ bị tính là "lỗi mạng". Chỉ bọc request tới API của chính bạn. AbortSignal.timeout()némTimeoutError, không phảiAbortError.trackedFetchchỉ bỏ quaAbortError(mình chủ động huỷ). Timeout vẫn bị tính là lỗi mạng, và đó là hành vi hợp lý.- Tab ẩn. Bản viết lại vẫn ping khi tab ẩn. Muốn tiết kiệm hơn, tạm dừng heartbeat khi
document.visibilityState === 'hidden'và kiểm tra lại ởvisibilitychange. - Service worker. Nếu app có service worker cache "network-first, fallback cache", cả heartbeat lẫn request đi qua
trackedFetchcó thể nhận response từ cache dù đang offline, và bị tính là "online". HeaderCache-Control: no-storevàcache: 'no-store'giúp cho heartbeat, nhưng chắc nhất là để service worker bỏ qua hẳn URL heartbeat, và gắn một header riêng (ví dụx-from-sw: 1) vào response lấy từ cache đểtrackedFetchkhông gọireportSuccesscho chúng. - SSR.
createNetworkStatuschỉ gắn listeneronline/offlinekhi cówindow, nên import và tạo ở server không bị crash.useOnlinetruyềngetServerSnapshot = () => truechouseSyncExternalStore, vì server không biết trạng thái mạng của người dùng và giả định online là an toàn nhất. Sau khi hydrate, React tự đọc lạiisOnline()ở client. - Đừng chặn thao tác khi "offline". Trạng thái offline là phỏng đoán. Hãy hiện banner và cho phép thử lại, đừng vô hiệu hoá nút "Gửi". Biết đâu request đó lại thành công.
#Demo
Không thể rút dây mạng của bạn được, nên demo dùng một fetch giả. Bật Giả lập mất mạng, bấm Gọi API, xem heartbeat giãn dần (100 → 200 → 400…). Sau đó:
- Tắt công tắc rồi chờ: heartbeat kế tiếp thành công và trạng thái về online.
- Hoặc tắt công tắc rồi bấm Gọi API ngay: request thật thành công huỷ vòng heartbeat và về online tức thì, không phải chờ backoff.
- Bật chế độ Offline trong DevTools (tab Network): log hiện "ngừng ping", không còn request heartbeat nào cho tới khi bạn tắt Offline.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Dùng trong dự án
network.ts
export const network = createNetworkStatus({ heartbeatUrl: '/api/health' });
export const api = trackedFetch(network);
ts
function OfflineBanner() {
const online = useOnline(network);
return online ? null : <div role='status'>Mất kết nối. Đang thử kết nối lại…</div>;
}
tsx
Endpoint heartbeat nên:
- Trả về nhanh, body rỗng, không chạm database, không cần auth.
- Có header
Cache-Control: no-store, nếu không thì service worker hoặc CDN có thể trả về bản cache khi đang offline. - Cùng origin với app và chạy qua HTTPS, để kiểm tra đúng đường đi tới server của bạn và lộ được captive portal.
- Nếu muốn có "công tắc từ xa" giống Facebook, hãy quy ước một status hoặc header đặc biệt để client ngừng ping.
So với các thư viện có sẵn: hook useNetworkState trong các bộ hook như react-use hay @uidotdev/usehooks chỉ bọc navigator.onLine cùng hai event online/offline, tức là gặp đúng các hạn chế ở đầu bài. Còn TanStack Query có onlineManager để tạm dừng query khi offline, và bạn có thể gọi onlineManager.setOnline(...) mỗi khi network.subscribe báo thay đổi để nối hai thứ lại với nhau.
#Kiểm thử
fetchImpl có thể truyền vào nên test không cần mạng thật.
Ví dụ test (bản phác, không có trong repo)
networkStatus.test.ts
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createNetworkStatus } from './networkStatus';
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('createNetworkStatus', () => {
it('backoff gấp đôi rồi về online khi heartbeat thành công', async () => {
vi.useFakeTimers();
let down = true;
const delays: number[] = [];
const net = createNetworkStatus({
heartbeatUrl: '/hb',
fetchImpl: async () => {
if (down) throw new TypeError('Failed to fetch');
return {} as Response;
},
onHeartbeat: (e) => e.type === 'heartbeat-fail' && delays.push(e.retryIn),
});
net.reportError();
await vi.advanceTimersByTimeAsync(100 + 200 + 400);
expect(delays).toEqual([100, 200, 400, 800]);
expect(net.isOnline()).toBe(false);
down = false;
await vi.advanceTimersByTimeAsync(800);
expect(net.isOnline()).toBe(true);
});
it('heartbeat cũ về muộn không ghi đè trạng thái mới', async () => {
vi.useFakeTimers();
let failHeartbeat!: (error: unknown) => void;
const fetchImpl = vi.fn(
() => new Promise<Response>((_, reject) => (failHeartbeat = reject)),
);
const net = createNetworkStatus({ heartbeatUrl: '/hb', fetchImpl });
net.reportError(); // heartbeat lượt 0 bắt đầu và treo
net.reportSuccess(); // request thật thành công -> huỷ lượt 0
failHeartbeat(new TypeError('timeout')); // lượt 0 giờ mới lỗi
await vi.runAllTimersAsync();
expect(net.isOnline()).toBe(true);
expect(fetchImpl).toHaveBeenCalledTimes(1); // không có retry nào
});
it('không ping khi hệ điều hành báo offline', () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
const fetchImpl = vi.fn();
const net = createNetworkStatus({ heartbeatUrl: '/hb', fetchImpl });
window.dispatchEvent(new Event('offline'));
net.reportError();
expect(net.isOnline()).toBe(false);
expect(fetchImpl).not.toHaveBeenCalled();
});
});
ts
Test thứ hai chính là kịch bản race condition ở trên. Thử xoá dòng if (run !== heartbeatRun) return; trong code là test này đỏ ngay.
Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Phần 1
- Mở đầu: cách đọc code minify của Facebook
- #1 Dùng chung IntersectionObserver + infinite scroll
- #2 useViewportDuration: đo thời gian thực sự được xem
- #3 Preload code + data khi hover
- #4 Toast manager với external store
- #5 Network status + heartbeat backoff (bài này)
- #6 ErrorGuard: bắt lỗi theo ngữ cảnh
- #7 Banzai: gom log theo lô + sendBeacon
- #8 Focus trap + Esc theo layer
Phần 2
- #9 Mẹo calc(… * 9999): container query, line-clamp, mũi tên carousel bằng CSS
- #10 Thanh cuộn tuỳ biến bằng CSS 3D
- #11 Modal cuộn bằng window, giữ vị trí trang nền
- #12 Popover, tooltip group và hovercard
- #13 Primitive Pressable + focus-visible
- #14 Tab "Xem thêm" + sticky header bằng IntersectionObserver
- #15 Avatar khoét mask, gradient Messenger, skeleton steps()
- #16 Khung chat không giật
- #17 Thuật toán chọn bố cục collage ảnh
- #18 Keep-alive tab trong SPA
- #19 Tự đo Visual Completion & interaction tracing
- #20 Router SPA bền bỉ & tải chunk có retry
Phần 3
- #21 Smart Compose: gợi ý chữ mờ khi gõ
- #22 MQTT ngay trong trình duyệt, tự chuyển sang long-poll
- #23 Đo thời gian dùng bằng bitmap 64 giây
- #24 Bảng reaction nhấn giữ rồi trượt
- #25 Tìm kiếm gấp dấu, phiên âm và highlight đầu từ
- #26 Ring buffer biết trace nào đã mất log
- #27 Engine animation JS: một vòng rAF và solver cubic-bezier
- #28 Bootloader: bitmap tài nguyên đã tải, chỉ reload khi cần
- #29 Nhận biết click do screen reader tạo ra
- #30 Tải trước feed theo chất lượng mạng, đoán hạng thiết bị
- #31 Hàng đợi gửi tin chat giữ đúng thứ tự
- #32 Gộp lời gọi API trong cùng một tick
- #33 Resize ảnh trong pool Web Worker
- #34 Crop ảnh theo nhiều khuôn mặt
- #35 Kéo-thả sắp xếp và vùng thả file không nhấp nháy
- #36 Tiện ích UI: fade-out, sticky event, hàm huỷ được
- #37 Hạ tầng nhỏ: cookie, hash lỗi, prefetch khi chuột dừng
Phần 4–6
- #38 Đồng bộ tin nhắn: phát hiện lỗ seqID và fetch bù theo tầng
- #39 OTID: ID tin nhắn kiểu Snowflake sinh ở client
- #40 QPL: lấy mẫu từ lúc bắt đầu, đo RTT gửi tin đầu-cuối
- #41 Bộ điều phối request bảy chế độ gộp và chống trùng
- #42 Thread list theo mốc thời gian, cookie presence có version
- #43 Composer dạng plugin: reducer, dirty checker, chuỗi kiểm tra
- #44 Thanh toán bất đồng bộ: subscribe trước, poll dự phòng
- #45 Validate số thẻ: sửa lỗi gõ, dải BIN, Luhn
- #46 Lịch chọn khoảng ngày: bo góc dải chọn, phím đủ bộ, RTL
- #47 Giờ mở cửa theo tuần: qua đêm, gộp khoảng, phần bù
- #48 Tiến độ đăng realtime: giữ event chờ snapshot, watchdog
- #49 Tiền tệ theo đơn vị nhỏ nhất và ô nhập tiền
- #50 Sửa hàng loạt Empty/Uniform/Mixed, seen/read bằng 2 bit
- #51 Phát hiện script lạ hook vào hàm native
- #52 localStorage tự dọn khi đầy quota
- #53 Cache tuyến do server phân giải, chờ sẵn promise
- #54 Lưới phím mũi tên 2D và focus không làm cuộn trang
- #55 Tách emoji bằng máy trạng thái
- #56 Đọc trước ghi sau trong một frame, con tự biết vị trí
- #57 Hàng đợi lệnh cho video và stream sắp lại chunk
Phần 7–10
- #58 Ước lượng băng thông EWMA: loại outlier, nhớ qua phiên
- #59 ABR theo độ tự tin phát và cắt bậc bitrate theo MOS
- #60 Prefetch segment dự đoán từ manifest, parse hộp sidx
- #61 Đọc metadata MP4/MOV ngay trên client
- #62 Upload tiếp tục được: offset, hash dedupe, phát hiện treo
- #63 Fenwick tree: chọn ngẫu nhiên có trọng số và offset dòng
- #64 Vạch trục số và trục thời gian "đẹp"
- #65 Damerau-Levenshtein theo dải chéo và tự sửa tên gõ sai
- #66 PRNG có seed, shuffle tái lập và lấy mẫu trace
- #67 Cặp min/max-heap tái dùng vị trí trong danh sách ảo
- #68 Memo nhiều đối số bằng Map/WeakMap và interning object
- #69 Thống kê cửa sổ trượt và đại số khoảng thời gian
- #70 Int64 từ hai số 32-bit, decode UTF, offset theo code point
- #71 Engine múi giờ IANA trên client, phát hiện lệch giờ
- #72 Tự cài semver range
- #73 Auto-link URL, cắt chữ an toàn, giới hạn LaTeX
- #74 Parser query kiểu PHP chống prototype pollution
- #75 Popover tự lật theo phần tràn nhỏ nhất
- #76 Tiền tố for(;😉 chống JSON hijacking, đọc stream
- #77 Chống lộ Referer, linkshim, xoá token trên URL
- #78 setImmediate bằng postMessage, gom delta chuột theo frame
- #79 Phát hiện crash qua heartbeat, dò proxy cache hỏng
- #80 DPR vào cookie cho ảnh @2x, che khoá trong log
- #81 Flux waitFor tự viết và proxy có đệm
- #82 Crop đúng tỉ lệ, xoay sticker bằng ma trận
Phần 11–15
- #83 Chia subdomain giữa các tab bằng lease, long-poll theo bảng trạng thái
- #84 MQTT QoS1: giữ publish chờ ack, gửi lại khi kết nối lại
- #85 Cache Promise gộp request, cache lỗi ngắn, đua dữ liệu nhúng sẵn
- #86 Lịch retry: backoff có trần, Fibonacci, báo lỗi CDN mỗi N ngày
- #87 Giữ nguyên tham chiếu nhánh dữ liệu không đổi
- #88 Store optimistic: copy-on-write, rollback rồi áp lại
- #89 Chỉ mục subscription theo record, hàng đợi task theo chiều sâu
- #90 Phân trang connection: cursor khớp đầu, edge không trùng
- #91 Profiler kiểu middleware và memo selector có lấy mẫu kiểm tra
- #92 Đọc EBML/WebM Cues để tìm byte range của segment
- #93 Stream HTTP Range tạm dừng được, tải tiếp đúng byte
- #94 Bảng khoảng thời gian gắn nhãn và nhảy qua lỗ buffer
- #95 Gom chunk cho SourceBuffer, đồng bộ video bằng playbackRate
- #96 Gom tin nhắn thành cụm, vạch ngày và avatar đã xem
- #97 Bao nhiêu tab chat vừa màn hình, chuỗi luật mở tab
- #98 Phím tắt Markdown trong editor, Backspace để hoàn tác
- #99 Offset theo code point hay UTF-16: bốn bug cùng một gốc
- #100 Typeahead không nhảy: cache tiền tố, chặn reflow, khoá hover
- #101 Hit-test trên canvas bằng phát lại path, nét đứt và cung Bézier
- #102 Vòng tròn theo mét trên bản đồ Mercator
- #103 Dò tính năng trình duyệt: ảnh EXIF 2×1, getter bẫy, scrollbar
- #104 Bug định dạng số và kiểm tra dữ liệu theo locale
- #105 Dispose cho đúng: idle callback, splice(-1), xoá nhầm key
Code + demo: examples/facebook-patterns · Mở trên StackBlitz

