Học từ web lớn #15: Đồng bộ đăng nhập giữa tab, dọn cookie (Elastic, InVision)
Elastic Cloud chỉ reload các tab khác khi trạng thái đăng nhập thật sự đổi; InVision dọn cookie analytics trước khi vượt 16KB. Hai mẹo nhỏ về phiên đăng nhập.
Hai lỗi về phiên đăng nhập mà người dùng gặp thường xuyên nhưng ít khi báo lại:
- Đăng xuất ở tab này, tab kia vẫn "đăng nhập". Người dùng mở hai tab Elastic Cloud, đăng xuất ở tab A rồi quay sang tab B. Tab B vẫn hiện dữ liệu cũ, bấm gì cũng lỗi 401 vì token đã bị xoá. Ngược lại cũng vậy: đăng nhập ở tab A, tab B vẫn đứng ở màn hình đăng nhập.
- App tự nhiên hỏng, xoá cookie thì hết. Các script analytics (Amplitude, Mixpanel…) ghi cookie vào domain của bạn, mỗi cookie vài KB, càng dùng càng nhiều. Tới một ngày, tổng header
Cookievượt giới hạn của proxy/CDN và mọi request đều bị trả lỗi 400 hoặc 431. Với người dùng, app "chết hẳn" mà không có thông báo gì dễ hiểu.
Elastic Cloud và InVision mỗi bên có một đoạn code nhỏ cho đúng một trong hai lỗi trên.
Tip
Tóm tắt
- Lưu thời điểm hết hạn của token vào localStorage (không lưu token). Các tab khác nghe sự kiện
storagevà chỉ reload khi trạng thái đổi: khách ↔ đã đăng nhập, có/không quyền sudo. Làm mới token (hạn mới, vẫn hợp lệ) thì không làm gì. - So sánh với trạng thái tab đang hiển thị, không so
oldValue/newValuecủa sự kiện: an toàn cả khi trình duyệt bắn sự kiện nhầm vào chính tab vừa ghi. - Đo tổng kích thước
document.cookie; vượt ngưỡng an toàn (InVision: 13KB, dưới mức 16KB của Cloudflare) thì xoá các cookie "dọn được" như cookie analytics. - Xoá cookie bằng
Max-Age=0(hoặc một ngày hợp lệ trong quá khứ) và đúng domain/path đã dùng khi ghi.expires=<một con số>không xoá được cookie.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Elastic Cloud làm thế nào?
Component cloud.elastic.co/components/CrossTabAuthReplication/index.ts (xem trên GitHub) là một React component render null, được gắn một lần ở gốc app (trong AppRootSharedSideEffects).
#Chỉ lưu thời điểm hết hạn
Hàm SAD_updateAuthTokenBits trong lib/auth.ts giải mã JWT và ghi hai khoá vào localStorage:
localStorage.setItem('CLOUD_AUTH_EXPIRES', JSON.stringify(decoded.exp ? decoded.exp * 1000 : null));
localStorage.setItem('CLOUD_AUTH_EXPIRES_SUDO', JSON.stringify(decoded.sudo ? decoded.sudo * 1000 : null));
ts
Token thật không nằm ở đây. localStorage chỉ giữ hai con số: token hết hạn lúc nào, quyền sudo (quyền quản trị tạm thời, phải nhập lại mật khẩu) hết hạn lúc nào. Đăng xuất thì removeItem cả hai. (Tiền tố SAD_ là của chính Elastic: comment ghi rằng họ muốn bỏ các hàm phải tự đoán trạng thái từ JWT, thay bằng hỏi API.)
#Chỉ reload khi trạng thái đổi
Component nghe hai khoá đó bằng thư viện local-storage (npm, bọc sự kiện storage và trả về giá trị mới/cũ đã JSON.parse). Comment đầu file liệt kê năm tình huống làm hai khoá thay đổi: đăng nhập, đăng xuất, lấy sudo, bỏ sudo, và token được làm mới tự động (RefreshApiToken). Bốn tình huống đầu cần reload; tình huống cuối thì không:
hasExpirationStateChange(nextExpiration, prevExpiration) {
if (nextExpiration === prevExpiration) return false;
const nextDate = nextExpiration ? new Date(nextExpiration) : null;
const prevDate = prevExpiration ? new Date(prevExpiration) : null;
// Token was expired and now is not, or token was unexpired and now is not
return expiresInTheFuture(nextDate) !== expiresInTheFuture(prevDate);
}
ts
Điểm mấu chốt: so trạng thái (expiresInTheFuture: còn hạn hay không), không so giá trị. Token được làm mới thì hạn đổi từ 10:00 sang 11:00, nhưng cả hai đều "còn hạn", nên không reload. Comment giải thích: reload lúc đó chỉ làm phiền người dùng.
Khi cần reload, hàm fullPageReload() tải lại trang, với một trường hợp đặc biệt: đang ở /login?fromURI=... (đăng nhập qua Okta) thì chuyển thẳng tới fromURI thay vì reload, vì reload trang login với tham số đó sẽ kích hoạt đăng xuất.
#Tắt hẳn trên Safari
const featureDetectedSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
componentDidMount() {
// Safari fires storage events in the same tab where changes originate, causing issues
// https://bugs.webkit.org/show_bug.cgi?id=210512
if (featureDetectedSafari) return;
...
}
ts
Theo comment, Safari bắn sự kiện storage ở chính tab vừa ghi, trái với chuẩn (chuẩn chỉ bắn ở các document khác). Với logic "nhận sự kiện là reload", tab vừa đăng nhập sẽ tự reload chính nó. Elastic chọn tắt hẳn tính năng trên Safari bằng cách dò user agent. Regex này khớp mọi chuỗi có "safari" mà không có "chrome" hay "android", tức là cả Chrome và Firefox trên iOS (vì UA của chúng là "CriOS", "FxiOS"), điều này hợp lý vì mọi trình duyệt trên iOS đều chạy WebKit.
Bản viết lại bên dưới không cần dò Safari: nó so với trạng thái mà tab đang hiển thị, và tab vừa ghi đã cập nhật trạng thái đó trước, nên một sự kiện bắn nhầm không gây reload.
#InVision làm thế nào?
File invisionapp.com(dashboard)/utils/cookieSizeHelper.js (xem trên GitHub) mở đầu bằng một comment rất rõ: bảo vệ trước việc các thư viện phía client làm cookie phình to tới mức server lỗi, vì vượt cấu hình large_client_header_buffers của nginx. Hàm bên trong tên là cookieMonster:
// We have updated our header allowance to 24k but
// cloudflare enforces the cookie limit at 16k.
const maxBytes = 1024 * 13 // 13k
const currentBytes = encodeURI(document.cookie).split(/%..|./).length - 1
const disposableCookieNameIncludes = ['amplitude', 'mp_']
js
- Ngưỡng 13KB: họ đã nâng giới hạn header của nginx lên 24KB, nhưng theo comment, Cloudflare (đứng trước nginx) giới hạn cookie ở 16KB. 13KB chừa khoảng trống cho các cookie được ghi thêm giữa hai lần kiểm tra ("để không có sự cố trong các lời gọi bất đồng bộ").
- Đếm byte bằng
encodeURI:encodeURIbiến mỗi ký tự ngoài ASCII thành các cụm%XX, mỗi cụm là một byte UTF-8. Regex/%..|./khớp từng cụm%XXhoặc từng ký tự thường, nên số mảnh sausplittrừ 1 chính là số byte. Một mẹo cũ trước khi cóTextEncoder. - Chỉ xoá cái biết chắc là dọn được: cookie có tên chứa
amplitudehoặcmp_(Mixpanel). Xoá nhầm cookie phiên đăng nhập là đăng xuất người dùng. - Chạy lúc rảnh: gọi qua
requestIdleCallback, không có thìsetTimeout(…, 7000), sau khi phần "home" của app shell mount xong.
Hàm xoá cookie có một lỗi nhỏ:
document.cookie = `${name}=;expires=${+new Date()};domain=.invisionapp.com;path=/`
js
+new Date() là một con số (vd 1761550000000), không phải ngày theo định dạng cookie. Theo RFC 6265, thuộc tính Expires không đọc được thì bị bỏ qua, nên dòng trên thực chất ghi đè cookie thành giá trị rỗng, không có hạn (cookie phiên), chứ không xoá nó. Mình đã thử trên Chromium: document.cookie vẫn còn amplitude_x= sau dòng đó. Kích thước vẫn giảm (giá trị đã rỗng) nên mục đích chính vẫn đạt, nhưng tên cookie còn đó cho tới khi đóng trình duyệt. Bật "Xoá kiểu bản gốc" trong demo để thấy các cookie rỗng còn lại.
#Bên trong trình duyệt
Sự kiện storage bắn ở mọi document khác cùng origin khi localStorage thay đổi: tab khác, iframe khác. Document vừa ghi không nhận sự kiện. Sự kiện có key, oldValue, newValue, storageArea; localStorage.clear() thì key === null.
Header Cookie chứa mọi cookie khớp domain và path của request, nối bằng ; . Mỗi cookie bị trình duyệt giới hạn khoảng 4KB, nhưng không có giới hạn chung nào cho tổng số byte gửi đi: 20 cookie analytics 1KB là 20KB trong mỗi request, kể cả request tải ảnh. Server và proxy thì có giới hạn: nginx mặc định 8KB cho một dòng header (trả lỗi 400 "Request Header Or Cookie Too Large"), mã lỗi chuẩn cho trường hợp này là 431.
document.cookie không thấy cookie HttpOnly. Cookie phiên đăng nhập thường là HttpOnly, nên con số đo được luôn nhỏ hơn header thật. Ngưỡng 13KB phải chừa chỗ cho những cookie không thấy được này.
flowchart LR
A["Tab A: đăng nhập / đăng xuất / làm mới token"] -->|ghi localStorage| S[(auth:expires)]
S -->|sự kiện storage| B["Tab B: check()"]
B --> C{"Trạng thái đổi so với lúc đang hiển thị?"}
C -- "khách → đã đăng nhập" --> R[reload]
C -- "còn hạn → còn hạn" --> N[không làm gì]
#Viết lại
#crossTabAuth.ts
src/lib/crossTabAuth.ts giữ ý tưởng của Elastic (chỉ lưu thời điểm hết hạn, so theo trạng thái) và đổi cách so sánh:
src/lib/crossTabAuth.ts
export function watchAuthState({ onStateChange, storage = localStorage, now = Date.now }: WatchAuthOptions) {
// Trạng thái mà TAB NÀY đang hiển thị. So sánh với nó, không so oldValue/newValue của sự kiện:
// sự kiện có bắn nhầm vào chính tab vừa ghi (Elastic ghi nhận lỗi này trên Safari) thì
// `known` đã được cập nhật trong write(), nên không có gì đổi và không reload.
let known = toAuthState(readAuth(storage), now());
function check() {
const next = toAuthState(readAuth(storage), now());
if (sameState(next, known)) return;
const prev = known;
known = next;
onStateChange(next, prev);
}
const onStorage = (event: StorageEvent) => {
if (event.storageArea !== storage) return;
// key === null: localStorage.clear() ở tab khác
if (event.key === null || event.key === AUTH_KEYS.expires || event.key === AUTH_KEYS.sudoExpires) {
check();
}
};
window.addEventListener('storage', onStorage);
return {
/** tab này đăng nhập / đăng xuất / làm mới token: ghi và cập nhật trạng thái đã biết */
write(snapshot: AuthSnapshot) {
writeKey(storage, AUTH_KEYS.expires, snapshot.expiresAt);
writeKey(storage, AUTH_KEYS.sudoExpires, snapshot.sudoExpiresAt);
known = toAuthState(snapshot, now());
},
getState: () => known,
/** kiểm tra lại bằng tay, vd khi tab được hiện lại */
check,
stop: () => window.removeEventListener('storage', onStorage),
};
}
ts
Dùng trong app:
const auth = watchAuthState({ onStateChange: () => location.reload() });
async function login(credentials) {
const { token } = await api.login(credentials); // token nằm trong cookie HttpOnly
const { exp } = decodeJwt(token);
auth.write({ expiresAt: exp * 1000, sudoExpiresAt: null });
}
ts
Xem toàn bộ src/lib/crossTabAuth.ts
src/lib/crossTabAuth.ts
/**
* Đồng bộ trạng thái đăng nhập giữa các tab (ý tưởng từ CrossTabAuthReplication của Elastic Cloud).
*
* - localStorage chỉ giữ THỜI ĐIỂM HẾT HẠN của token (và của quyền sudo), không giữ token.
* - Tab khác ghi vào localStorage -> sự kiện `storage` bắn ở tab này.
* - Chỉ phản ứng (thường là reload) khi TRẠNG THÁI đổi: khách <-> đã đăng nhập, có/không sudo.
* Token được làm mới (hạn mới, vẫn còn hiệu lực) thì không làm gì: reload lúc đó chỉ làm phiền.
*/
export const AUTH_KEYS = {
expires: 'auth:expires',
sudoExpires: 'auth:sudo-expires',
} as const;
export interface AuthSnapshot {
/** epoch ms, null = chưa đăng nhập */
expiresAt: number | null;
sudoExpiresAt: number | null;
}
export interface AuthState {
loggedIn: boolean;
sudo: boolean;
}
const inFuture = (time: number | null, now: number) => time !== null && time > now;
export function toAuthState(snapshot: AuthSnapshot, now = Date.now()): AuthState {
return {
loggedIn: inFuture(snapshot.expiresAt, now),
sudo: inFuture(snapshot.sudoExpiresAt, now),
};
}
function readNumber(storage: Storage, key: string): number | null {
try {
const value = JSON.parse(storage.getItem(key) ?? 'null');
return typeof value === 'number' ? value : null;
} catch {
return null; // giá trị hỏng coi như không có
}
}
export function readAuth(storage: Storage = localStorage): AuthSnapshot {
return {
expiresAt: readNumber(storage, AUTH_KEYS.expires),
sudoExpiresAt: readNumber(storage, AUTH_KEYS.sudoExpires),
};
}
function writeKey(storage: Storage, key: string, value: number | null) {
if (value === null) storage.removeItem(key);
else storage.setItem(key, JSON.stringify(value));
}
const sameState = (a: AuthState, b: AuthState) => a.loggedIn === b.loggedIn && a.sudo === b.sudo;
export interface WatchAuthOptions {
/** trạng thái đổi do tab KHÁC: thường gọi location.reload() */
onStateChange: (next: AuthState, prev: AuthState) => void;
storage?: Storage;
now?: () => number;
}
export function watchAuthState({ onStateChange, storage = localStorage, now = Date.now }: WatchAuthOptions) {
// Trạng thái mà TAB NÀY đang hiển thị. So sánh với nó, không so oldValue/newValue của sự kiện:
// sự kiện có bắn nhầm vào chính tab vừa ghi (Elastic ghi nhận lỗi này trên Safari) thì
// `known` đã được cập nhật trong write(), nên không có gì đổi và không reload.
let known = toAuthState(readAuth(storage), now());
function check() {
const next = toAuthState(readAuth(storage), now());
if (sameState(next, known)) return;
const prev = known;
known = next;
onStateChange(next, prev);
}
const onStorage = (event: StorageEvent) => {
if (event.storageArea !== storage) return;
// key === null: localStorage.clear() ở tab khác
if (event.key === null || event.key === AUTH_KEYS.expires || event.key === AUTH_KEYS.sudoExpires) {
check();
}
};
window.addEventListener('storage', onStorage);
return {
/** tab này đăng nhập / đăng xuất / làm mới token: ghi và cập nhật trạng thái đã biết */
write(snapshot: AuthSnapshot) {
writeKey(storage, AUTH_KEYS.expires, snapshot.expiresAt);
writeKey(storage, AUTH_KEYS.sudoExpires, snapshot.sudoExpiresAt);
known = toAuthState(snapshot, now());
},
getState: () => known,
/** kiểm tra lại bằng tay, vd khi tab được hiện lại */
check,
stop: () => window.removeEventListener('storage', onStorage),
};
}
ts
#cookieGuard.ts
src/lib/cookieGuard.ts
export function guardCookieSize({
maxBytes = 13 * 1024,
disposable = ['amplitude', 'mp_'],
domain,
path = '/',
jar = documentJar,
}: GuardOptions = {}): GuardReport {
const before = byteLength(jar.read());
const report: GuardReport = { before, after: before, removed: [] };
if (before <= maxBytes) return report;
// Xoá cookie to nhất trước, dừng khi đã xuống dưới ngưỡng.
const candidates = listCookies(jar.read())
.filter((c) => disposable.some((part) => c.name.includes(part)))
.sort((a, b) => b.bytes - a.bytes);
for (const cookie of candidates) {
// Max-Age=0 là cách xoá chắc chắn. Muốn dùng expires thì phải là chuỗi ngày hợp lệ
// (vd 'Thu, 01 Jan 1970 00:00:00 GMT'); một con số như `expires=1700000000000` bị trình
// duyệt bỏ qua, cookie chỉ bị đặt lại thành rỗng chứ không mất.
jar.write(`${cookie.name}=; Max-Age=0; path=${path}`);
// cookie có thể được ghi kèm domain (dùng chung cho subdomain): phải xoá đúng domain đó
if (domain) jar.write(`${cookie.name}=; Max-Age=0; path=${path}; domain=${domain}`);
report.removed.push(cookie.name);
if (byteLength(jar.read()) <= maxBytes) break;
}
report.after = byteLength(jar.read());
return report;
}
ts
Gọi một lần sau khi app tải xong: scheduleCookieGuard({ domain: '.example.com' }). Hàm này chạy guard bằng requestIdleCallback (có timeout 10s để chắc chắn chạy), không có thì setTimeout 5s.
Xem toàn bộ src/lib/cookieGuard.ts
src/lib/cookieGuard.ts
/**
* Chặn cookie phình to tới mức server/CDN từ chối request (ý tưởng từ cookieSizeHelper của InVision).
*
* Mọi cookie của domain đi kèm MỌI request trong header `Cookie`. Script bên thứ ba (analytics,
* A/B test…) có thể ghi cookie ngày càng lớn; vượt giới hạn header của proxy/CDN thì request
* bị trả 4xx (thường là 400 hoặc 431, tuỳ server), người dùng thấy app hỏng mà không hiểu vì sao.
* InVision đo `document.cookie`, vượt 13KB (dưới mức 16KB của Cloudflare) thì xoá các cookie
* "dọn được" (amplitude*, mp_*).
*/
/** Số byte UTF-8 của chuỗi (InVision dùng mẹo encodeURI(s).split(/%..|./).length - 1). */
export const byteLength = (text: string) => new TextEncoder().encode(text).length;
/** Nơi đọc/ghi cookie; mặc định là document.cookie, demo/test thay bằng bản giả. */
export interface CookieJar {
read(): string;
write(cookie: string): void;
}
export const documentJar: CookieJar = {
read: () => document.cookie,
write: (cookie) => {
document.cookie = cookie;
},
};
export interface CookieInfo {
name: string;
bytes: number;
}
export function listCookies(header: string): CookieInfo[] {
return header
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => ({ name: part.split('=')[0], bytes: byteLength(part) }));
}
export interface GuardOptions {
/** ngưỡng bắt đầu dọn, mặc định 13KB như InVision */
maxBytes?: number;
/** tên cookie chứa chuỗi này thì được phép xoá */
disposable?: string[];
/** domain đã dùng khi script kia ghi cookie, vd '.example.com' */
domain?: string;
path?: string;
jar?: CookieJar;
}
export interface GuardReport {
before: number;
after: number;
removed: string[];
}
export function guardCookieSize({
maxBytes = 13 * 1024,
disposable = ['amplitude', 'mp_'],
domain,
path = '/',
jar = documentJar,
}: GuardOptions = {}): GuardReport {
const before = byteLength(jar.read());
const report: GuardReport = { before, after: before, removed: [] };
if (before <= maxBytes) return report;
// Xoá cookie to nhất trước, dừng khi đã xuống dưới ngưỡng.
const candidates = listCookies(jar.read())
.filter((c) => disposable.some((part) => c.name.includes(part)))
.sort((a, b) => b.bytes - a.bytes);
for (const cookie of candidates) {
// Max-Age=0 là cách xoá chắc chắn. Muốn dùng expires thì phải là chuỗi ngày hợp lệ
// (vd 'Thu, 01 Jan 1970 00:00:00 GMT'); một con số như `expires=1700000000000` bị trình
// duyệt bỏ qua, cookie chỉ bị đặt lại thành rỗng chứ không mất.
jar.write(`${cookie.name}=; Max-Age=0; path=${path}`);
// cookie có thể được ghi kèm domain (dùng chung cho subdomain): phải xoá đúng domain đó
if (domain) jar.write(`${cookie.name}=; Max-Age=0; path=${path}; domain=${domain}`);
report.removed.push(cookie.name);
if (byteLength(jar.read()) <= maxBytes) break;
}
report.after = byteLength(jar.read());
return report;
}
/** Chạy guard lúc trình duyệt rảnh, không tranh CPU với lúc tải trang. */
export function scheduleCookieGuard(options?: GuardOptions, onReport?: (report: GuardReport) => void) {
const run = () => {
const report = guardCookieSize(options);
if (report.removed.length) onReport?.(report);
};
if ('requestIdleCallback' in window) {
const id = requestIdleCallback(run, { timeout: 10_000 });
return () => cancelIdleCallback(id);
}
const id = setTimeout(run, 5000);
return () => clearTimeout(id);
}
ts
#Đi qua code
known là trạng thái tab đang hiển thị. Mỗi lần có sự kiện, check() đọc lại cả hai khoá, tính trạng thái mới và so với known. Nhờ vậy:
- Hai khoá được ghi liên tiếp (hết hạn, rồi sudo) tạo ra hai sự kiện; sự kiện thứ hai thấy trạng thái đã khớp, không gọi
onStateChangelần nữa. - Tab tự ghi qua
write()thì cập nhậtknownngay, nên nếu trình duyệt bắn nhầm sự kiện vào chính tab đó,check()thấy không có gì đổi.
event.key === null là trường hợp localStorage.clear() ở tab khác (vd đăng xuất bằng cách xoá sạch). Bản của Elastic đăng ký callback theo từng tên khoá, nên sự kiện của clear() (không có tên khoá) có lẽ không tới được callback.
readNumber chịu được giá trị hỏng. Ai đó (extension, code cũ) ghi một chuỗi không phải JSON thì coi như "chưa đăng nhập", không để JSON.parse ném lỗi trong listener.
Guard xoá từ cookie to nhất và dừng ngay khi đã xuống dưới ngưỡng, thay vì xoá mọi cookie khớp mẫu. Cookie analytics nhỏ được giữ lại, số liệu của đội marketing ít bị ảnh hưởng hơn.
Ghi xoá hai lần khi có domain. Cookie a ghi với domain=.example.com và cookie a không có domain (host-only) là hai cookie khác nhau. Không biết script kia ghi kiểu nào thì xoá cả hai.
Khác bản gốc:
- Elastic: so với trạng thái tab đang hiển thị thay vì so giá trị cũ/mới của sự kiện, nên không phải tắt tính năng trên Safari; xử lý thêm
localStorage.clear(); dùng sự kiệnstoragetrực tiếp, không cần thư viện. Không có trường hợp đặc biệt/login?fromURI(phụ thuộc luồng Okta của Elastic). - InVision: xoá bằng
Max-Age=0; xoá từ to tới nhỏ và dừng khi đủ; đếm byte bằngTextEncoder; nhậnjartừ ngoài để demo/test không đụng cookie thật.
#Tại sao không làm cách khác?
BroadcastChannel thay cho sự kiện storage? Hoàn toàn được, và rõ ràng hơn: tab đăng nhập gửi { type: 'auth-changed' }, các tab khác nhận và kiểm tra. Nhưng vẫn cần localStorage (hoặc cookie) để tab mới mở biết trạng thái hiện tại; khi đã ghi localStorage thì sự kiện storage có sẵn, không cần thêm kênh.
Reload mọi tab mỗi khi token đổi? Đó là "cách ngây thơ" trong demo. Token được làm mới tự động mỗi vài phút thì mọi tab khác reload mỗi vài phút, mất form đang nhập dở.
Không reload, chỉ cập nhật state? Về lý thuyết đẹp hơn: đổi người dùng trong store, refetch dữ liệu. Nhưng dữ liệu cũ nằm rải rác khắp nơi (cache của data layer, state cục bộ của component, URL). Comment của Elastic nói thẳng: reload toàn trang để tránh trạng thái UI "không chắc chắn" với dữ liệu đã tải bằng quyền cũ. Đăng nhập/đăng xuất đủ hiếm để một lần reload là chấp nhận được.
Server tự từ chối cookie analytics? Server không xoá được cookie mà trình duyệt chưa gửi tới được (request bị proxy chặn trước khi tới app). Và một khi header đã quá to, mọi request đều lỗi, server không có cơ hội trả về Set-Cookie để dọn. Phải dọn từ phía client, trước khi chạm ngưỡng.
#Edge case và cạm bẫy
- Token hết hạn khi không có ai ghi gì: không có sự kiện
storage, tab vẫn hiển thị như đang đăng nhập. Gọiwatcher.check()khi tab được hiện lại (visibilitychange) hoặc hẹn giờ đúng lúc hết hạn; lúc đóonStateChangenên chuyển về trang đăng nhập thay vì reload. - Form đang nhập dở: reload vì đăng xuất ở tab khác sẽ làm mất dữ liệu. Có thể hỏi trước (giống unload hook trong bài router của series Facebook) hoặc lưu nháp trước khi reload.
- Đồng hồ máy người dùng sai: trạng thái "còn hạn" tính bằng
Date.now()của máy. Máy chạy sai giờ vài tiếng thì tab có thể nghĩ token đã hết hạn. Nên tính lệch giờ với server (headerDatecủa response) nếu cần chính xác. - Cookie guard không thấy cookie
HttpOnlyvà không thấy cookie của path khác: con số đo được là cận dưới. Đặt ngưỡng thấp hơn giới hạn thật một khoảng rộng. - Script analytics ghi lại ngay: xoá cookie Amplitude xong, lần sau SDK chạy nó lại tạo cookie mới (nhỏ, vì là phiên mới). Nếu cookie phình nhanh, nên cấu hình SDK lưu vào localStorage thay vì cookie (nhiều SDK có tuỳ chọn này).
- Phải xoá đúng domain: cookie ghi ở
.example.commà xoá ởapp.example.com(không ghi domain) thì không mất. InVision ghi cứngdomain=.invisionapp.comvì biết các script kia ghi ở đó.
#Dùng trong dự án
- Mọi app có đăng nhập mà người dùng hay mở nhiều tab: dashboard, admin, app SaaS. Chỉ cần
watchAuthStateở gốc app và gọiwrite()ở chỗ đăng nhập/đăng xuất/làm mới token. - Quyền tạm thời (sudo, "xác nhận lại mật khẩu trong 10 phút"): thêm một khoá hết hạn, so trạng thái giống hệt.
- Site có nhiều script bên thứ ba ghi cookie trên domain chính:
scheduleCookieGuardvới danh sách tên cookie bạn biết chắc là dọn được, và gửi log mỗi lần guard phải dọn để biết script nào đang phình.
Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it, vi } from 'vitest';
import { AUTH_KEYS, watchAuthState } from './crossTabAuth';
import { guardCookieSize, type CookieJar } from './cookieGuard';
const fire = (key: string | null) =>
window.dispatchEvent(new StorageEvent('storage', { key, storageArea: localStorage }));
describe('watchAuthState', () => {
it('chỉ báo khi trạng thái đổi', () => {
localStorage.clear();
const now = 1_000;
const onStateChange = vi.fn();
const w = watchAuthState({ onStateChange, now: () => now });
localStorage.setItem(AUTH_KEYS.expires, JSON.stringify(now + 3600_000)); // tab khác đăng nhập
fire(AUTH_KEYS.expires);
expect(onStateChange).toHaveBeenCalledTimes(1);
localStorage.setItem(AUTH_KEYS.expires, JSON.stringify(now + 7200_000)); // làm mới token
fire(AUTH_KEYS.expires);
expect(onStateChange).toHaveBeenCalledTimes(1);
localStorage.clear(); // tab khác xoá sạch
fire(null);
expect(onStateChange).toHaveBeenLastCalledWith(
{ loggedIn: false, sudo: false },
{ loggedIn: true, sudo: false },
);
w.stop();
});
it('sự kiện bắn nhầm vào tab vừa ghi không gây reload', () => {
localStorage.clear();
const onStateChange = vi.fn();
const w = watchAuthState({ onStateChange });
w.write({ expiresAt: Date.now() + 3600_000, sudoExpiresAt: null });
fire(AUTH_KEYS.expires);
expect(onStateChange).not.toHaveBeenCalled();
});
});
describe('guardCookieSize', () => {
it('xoá cookie analytics to nhất tới khi dưới ngưỡng', () => {
const store = new Map([['session', 'x'.repeat(100)], ['mp_a', 'x'.repeat(600)], ['amplitude_b', 'x'.repeat(300)]]);
const jar: CookieJar = {
read: () => [...store].map(([k, v]) => `${k}=${v}`).join('; '),
write: (c) => { if (/max-age=0/i.test(c)) store.delete(c.split('=')[0]); },
};
const report = guardCookieSize({ jar, maxBytes: 600 });
expect(report.removed).toEqual(['mp_a']);
expect(store.has('amplitude_b')).toBe(true);
});
});
ts
#So với thư viện và chuẩn web
- Cookie Store API (
cookieStore.getAll(), sự kiệnchange) đọc/ghi cookie bất đồng bộ, có kích thước và thuộc tính rõ ràng, không phải parse chuỗi. Chrome/Edge đã có từ lâu; các trình duyệt khác mới bổ sung hoặc chưa đầy đủ, kiểm tra caniuse (tính tới 10/2026) trước khi bỏ hẳndocument.cookie. - BroadcastChannel là kênh nhắn tin giữa các tab cùng origin, dùng khi cần gửi nhiều hơn một tín hiệu "đã đổi".
- Các thư viện auth (Auth0 SPA SDK, NextAuth/Auth.js…) có cơ chế đồng bộ phiên giữa tab riêng, thường cũng dựa trên localStorage/BroadcastChannel; đọc tài liệu của thư viện trước khi tự thêm một lớp nữa.
- Phía server: nếu kiểm soát được proxy, nâng giới hạn header là cách chữa triệu chứng; dọn cookie từ client (và cấu hình SDK analytics không dùng cookie) là chữa gốc.
Bài này thuộc series Học từ mã nguồn các web lớn (65 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Mở đầu
Phần 1: trải nghiệm người dùng
- #1 Dark mode không nháy (Gatsby)
- #2 useValueByMedia: giá trị theo breakpoint (Toptal)
- #3 Đoán ý định: idle, exit-intent, lắc chuột (Toptal, InVision)
- #4 Validate form thân thiện (Udacity, Pipedrive)
- #5 Magic number, EXIF và dán ảnh (Ello)
- #6 fastSmoothScroll: cuộn xa vẫn nhanh (Telegram)
- #7 Countdown time-travel và tombstone giỏ hàng (Best Buy)
Phần 2: hiệu năng và đo lường
- #8 Hàng đợi requestAnimationFrame 3ms (Apple)
- #9 srcset cho artwork và preconnect tự động (Apple)
- #10 Quick-fetch: gọi API trước khi bundle tải xong (HubSpot)
- #11 Performance span và long task (Postman, Calibre)
- #12 Gọi Web Worker như hàm async (Telegram)
- #13 Đồng hồ media: đếm giờ xem và sửa lệch (Skillshare, Simplified)
Phần 3: nhiều tab, phiên đăng nhập, cập nhật
- #14 Bầu tab chủ cho thông báo (HubSpot, Telegram)
- #15 Đồng bộ đăng nhập giữa tab và giới hạn cookie (Elastic, InVision) (bài này)
- #16 SPA tự cập nhật: Trello, Zappos, Facebook, Elastic
Phần 4: kiến trúc
- #17 Reverse portal: di chuyển component không remount (Zappos)
- #18 Page behaviors: gắn hành vi từ CMS (Intercom)
- #19 Giới hạn tần suất hiển thị bằng bitmask (Outlook)
- #20 Chuyển REST sang GraphQL dần dần (Coursera)
Phần 5: soạn thảo và hiển thị nội dung
- #21 Autocomplete tại con trỏ trong contenteditable (Ello, Telegram)
- #22 Dark mode cho email HTML bằng CIELAB (Outlook)
- #23 Thu nhỏ bảng rộng cho vừa khung, vá font emoji (Outlook)
- #24 Cảnh báo quên đính kèm file (Outlook)
- #25 Cắt chữ nhiều dòng chính xác (Pipedrive, Apple, Toptal)
- #26 Parse ngày giờ gõ tay khoan dung (Outlook, Elastic)
Phần 6: tương tác
- #27 menuAim: rê chéo vào submenu không bị đóng (Udemy)
- #28 Kéo, vuốt chung cho chuột và cảm ứng (Telegram)
- #29 Phím tắt theo phạm vi, IME và bàn phím phi Latinh (Trello)
- #30 Nội suy màu: đổi theme và nền theo cuộn (Telegram, Anchor)
- #31 Đường gióng hút cạnh và undo/redo trên canvas (Simplified)
- #32 Giả lập mù màu bằng feColorMatrix (Trello)
- #33 Vạch thước theo mức zoom và cấp phát màu cho biểu đồ (Anchor, Similarweb)
Phần 7: mạng, dữ liệu và async
- #34 Ô tìm kiếm chuẩn production (Similarweb, Udemy, Pipedrive)
- #35 Điều phối async: latest-wins, single-flight (Glitch, Toptal)
- #36 Polling thưa dần và debounce động (Trello)
- #37 Cache phía client theo version (Similarweb, Telegram, Udemy)
- #38 Upload file lớn theo chunk (Outlook, Tribe)
- #39 Phát video qua Service Worker bằng Range request (Telegram, Outlook)
Phần 8: hiệu năng và vận hành
- #40 TTI governor: hoãn việc phụ tới sau khi trang dùng được (Outlook)
- #41 Tự đo LCP/CLS/TBT và phục vụ bản nhẹ cho máy yếu (Udemy)
Phần 9: thuật toán và dữ liệu phía client
- #42 Three-way merge ngay trên trình duyệt (Postman)
- #43 Dọn cache GraphQL bằng đếm tham chiếu (Outlook)
- #44 LRU cache theo 3 cách (Apple, Similarweb, Outlook)
- #45 Lọc tăng dần trong Web Worker (Similarweb)
- #46 Xếp hạng gợi ý trên client, so ngầm với server (Outlook)
- #47 Nối OT với editor: undo stack và chặn echo (Glitch)
- #48 Ngôn ngữ markup riêng dịch hai chiều với HTML (Coursera)
Phần 10: thời gian, ngôn ngữ, định dạng
- #49 Xử lý giờ mùa hè không cần thư viện (Udemy, Pipedrive)
- #50 Intl thực chiến: Segmenter, số rút gọn, số nhiều, fallback locale (Apple, Outlook)
- #51 WebVTT và bôi chọn transcript (Udemy, Coursera)
Phần 11: tương tác và giao diện
- #52 Kéo-thả trên lịch (Pipedrive)
- #53 Focus stack cho popup lồng nhau (Outlook)
- #54 Clipboard: copy, dán chữ thuần, dán thông minh (Trello, Anchor, Pipedrive, Tribe)
- #55 Khôi phục vị trí cuộn khi trang còn đang tải (Apple, Zappos)
- #56 Lấy màu từ ảnh: gradient nền, màu chữ (Apple, Telegram, Trello)
- #57 JS đọc breakpoint từ CSS, phát hiện chế độ PWA (Ello, Apple)
Phần 12: media
- #58 Tin nhắn thoại: ghi âm, waveform realtime, nén 5-bit (Telegram)
- #59 Chụp frame video thành ảnh, canvas bị taint (Coursera, Outlook)
Phần 13: đo lường, bảo mật, vận hành
- #60 Đo lúc ảnh màn hình đầu tải xong, cả khi chuyển trang SPA (Zappos, Best Buy, Outlook)
- #61 Buffer analytics chờ SDK, phát hiện adblock (Best Buy)
- #62 Proof-of-work trên client để chặn API ẩn danh (Udemy)
- #63 Prefetch không làm lệch số liệu, nén cài đặt bằng bit (Outlook)
- #64 Micro-frontend: AngularJS và React chung app, giao tiếp xuyên frame (Similarweb, InVision, HubSpot)
Code + demo: examples/web-patterns · Mở trên StackBlitz

