Học từ Facebook #39: OTID, ID tin nhắn sinh ngay ở client
Messenger gắn cho mỗi tin một ID 63 bit sinh ở client: 41 bit thời gian, 22 bit ngẫu nhiên. Sắp được theo thời gian, khớp bản server, nhưng quay vòng năm 2039.
Người dùng bấm gửi. Tin phải hiện ngay trong khung chat, trước khi server trả lời. Vậy tin đó mang ID gì?
- ID của server chưa có: request còn đang bay.
- Dùng tạm
Date.now(): hai tin gửi cùng mili-giây (dán ba ảnh một lúc) trùng nhau. - Dùng UUID ngẫu nhiên: không trùng, nhưng không sắp được theo thời gian. Danh sách tin "đang gửi" đảo lộn khi render lại.
Và khi server dội tin về (qua kênh realtime, xem bài #38), client phải biết tin dội về chính là tin "đang gửi" nào để thay thế, không hiện hai lần.
Messenger web dùng một ID làm được cả ba việc: offline threading ID, gọi tắt OTID.
Tip
Tóm tắt
- OTID = 41 bit
Date.now()ghép 22 bit ngẫu nhiên, cắt còn 63 bit, viết dạng chuỗi thập phân. Sinh ngay ở client, không cần hỏi server. - Tin sinh sau (khác mili-giây) luôn có OTID lớn hơn; tin cùng mili-giây phân biệt nhờ 22 bit ngẫu nhiên.
- Tin gửi đi kèm OTID; delta "tin mới" dội về mang lại OTID; client ghép hai bản theo OTID.
- Bản gốc tự chia dài nhị phân → thập phân vì thời đó chưa có
BigInt; nay dùngBigIntgọn hơn. Cả hai sẽ quay vòng khiDate.now()vượt 41 bit (tháng 9/2039).
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Facebook làm thế nào?
#Sinh ID: MercuryLocalIDs (bundle 19.js)
Module chỉ có 3 hàm. Đổi tên biến cho dễ đọc:
function generateOfflineThreadingID(now) {
now = now != null ? now : Date.now();
let rand = randomInt(0, 4294967295); // 32 bit
rand = ('0000000000000000000000' + rand.toString(2)).slice(-22); // giữ 22 bit thấp
const bits = now.toString(2) + rand; // 41 + 22 = 63 ký tự
return binaryToDecimal(bits.slice(-63));
}
function generateThreadID(now) {
return 'root:' + generateOfflineThreadingID(now); // thread tạo ở client
}
js
binaryToDecimal chuyển chuỗi nhị phân sang chuỗi thập phân bằng phép chia dài cho 10, làm trên từng ký tự:
function binaryToDecimal(bits) {
let out = '';
while (bits != '0') {
let rem = 0, quotient = '';
for (const ch of bits) {
rem = 2 * rem + Number(ch);
if (rem >= 10) { quotient += '1'; rem -= 10; } else quotient += '0';
}
out = rem + out; // số dư là chữ số cuối
bits = quotient.slice(quotient.indexOf('1'));
}
return out;
}
js
Lý do có hàm này: số JavaScript (number, kiểu double) chỉ biểu diễn chính xác số nguyên tới 2^53. ID 63 bit vượt ngưỡng đó, nên không thể ghép bằng phép dịch bit thông thường. Code này viết từ trước khi BigInt có mặt ở mọi trình duyệt.
#Đi kèm khi gửi: MercuryServerRequests.sendNewMessage (bundle 55.js)
sendNewMessage(message) {
message.offline_threading_id || BanzaiODS.bumpEntityKey(3303, 'chat.web', 'missing_otid_on_send');
const { type, value } = MercuryIDs.tokenize(message.thread_id);
if (type === 'user') payload.other_user_fbid = value;
else if (type === 'thread') payload.thread_fbid = value;
else if (type === 'root' && value === payload.offline_threading_id)
payload.client_thread_id = message.thread_id; // thread mới tạo ở client
else /* thread cục bộ */ threadIDMap.getServerIDFromClientID(message.thread_id, …);
…
}
js
Thiếu OTID thì không chặn, chỉ tăng một bộ đếm (missing_otid_on_send) để đội chat biết có đường gửi nào quên sinh ID. Thread mới tạo ở client có ID root:<OTID>, chính bằng OTID của tin đầu tiên, nên server biết tin nào mở thread nào.
#Ghép khi dội về: MessengerState.bs
Delta NewMessage từ server mang messageMetadata.offlineThreadingId. Bộ chuyển delta (MessengerDeltaTransformer.bs) chép nó vào offline_threading_id. Khi cập nhật state, MessengerState.bs (viết bằng ReasonML) kiểm tra: nếu payload tới từ kênh realtime và trong state đang có tin cục bộ cùng OTID, thì gán client_message_id = offline_threading_id, status = SUCCESS cho bản server. Tin "đang gửi" được thay bằng tin thật, không sinh thêm dòng.
Cũng chính OTID làm khoá để đo độ trễ gửi tin đầu-cuối: xem bài #40.
sequenceDiagram participant U as Người dùng participant C as Client participant S as Server U->>C: bấm gửi C->>C: otid = time 41 bit + random 22 bit C->>C: hiện tin "đang gửi" với key otid C->>S: send_message kèm offline_threading_id S-->>C: delta NewMessage, messageId thật, kèm otid C->>C: tìm tin cục bộ cùng otid, thay bằng bản thật
#Bên trong: vì sao thứ tự thời gian được giữ?
Đặt thời gian ở bit cao, phần ngẫu nhiên ở bit thấp. Hai OTID sinh ở hai mili-giây khác nhau thì phần thời gian khác nhau, và phần đó quyết định so sánh, bất kể 22 bit ngẫu nhiên là gì. Trong cùng một mili-giây, thứ tự giữa các tin là ngẫu nhiên, nhưng xác suất trùng rất nhỏ: 2^22 ≈ 4,2 triệu giá trị, phải sinh khoảng 2.400 ID trong cùng một mili-giây thì xác suất có ít nhất một cặp trùng mới chạm 50% (bài toán ngày sinh).
Đây là cùng ý tưởng với Snowflake của Twitter (41 bit thời gian + 10 bit máy + 12 bit bộ đếm). Khác ở chỗ OTID không có ID máy và bộ đếm, vì mỗi client chỉ cần không trùng với chính nó trong cùng mili-giây.
Chuỗi, không phải số. OTID được truyền và lưu dưới dạng chuỗi thập phân. Nếu để JSON parse thành number, các chữ số cuối bị làm tròn và hai ID khác nhau thành một. So hai OTID cũng không được so chuỗi: "999" > "1000" theo thứ tự từ điển. Phải so độ dài trước, hoặc đổi sang BigInt.
#Quay vòng năm 2039
Date.now() hiện cần 41 bit. Tới 2039-09-07 15:47:35.552 UTC (2^41 ms), nó cần 42 bit. Lúc đó now.toString(2) + rand dài 64 ký tự, và .slice(-63) cắt mất bit cao nhất. OTID quay về một số rất nhỏ, nhỏ hơn mọi OTID sinh trước đó. Demo in ra hai giá trị quanh mốc này: một giây trước mốc là 9223372…, một giây sau là 4194304….
Còn 13 năm, và có lẽ server của Messenger đã đổi định dạng từ lâu. Nhưng nếu bạn chép nguyên thuật toán vào sản phẩm của mình thì nên biết.
#Viết lại
src/lib/otid.ts
export function generateOtid({ now = Date.now(), random = random22() }: OtidOptions = {}): string {
if (!Number.isInteger(now) || now < 0) throw new RangeError(`now không hợp lệ: ${now}`);
if (now >= OTID_TIME_LIMIT) {
// Bản gốc cắt lấy 63 bit cuối nên tới mốc này ID sẽ quay vòng về số nhỏ.
// Ở đây báo lỗi rõ ràng thay vì âm thầm sinh ID sai thứ tự.
throw new RangeError('Date.now() đã vượt 41 bit, cần đổi định dạng OTID');
}
const id = ((BigInt(now) << BigInt(OTID_RANDOM_BITS)) | (BigInt(random) & RANDOM_MASK)) & ID_MASK;
return id.toString();
}
/** Lấy lại thời điểm sinh (ms) từ một OTID. */
export function otidTime(otid: string): number {
return Number(BigInt(otid) >> BigInt(OTID_RANDOM_BITS));
}
/** So hai OTID theo giá trị số (không so chuỗi: "999" > "1000" theo chuỗi). */
export function compareOtid(a: string, b: string): number {
const x = BigInt(a);
const y = BigInt(b);
return x < y ? -1 : x > y ? 1 : 0;
}
ts
Khác bản gốc:
- Dùng
BigIntđể ghép bit thay cho chia dài trên chuỗi; test so 1.000 mẫu ngẫu nhiên, khớp từng chữ số với thuật toán gốc. - 22 bit ngẫu nhiên lấy từ
crypto.getRandomValuesthay choMath.random. - Tới mốc 2^41 thì ném
RangeError, không âm thầm sinh ID sai thứ tự. - Có thêm
otidTimeđể đọc lại thời điểm sinh, vàcompareOtidđể so theo giá trị.
Xem toàn bộ src/lib/otid.ts
src/lib/otid.ts
/**
* OTID (offline threading ID): ID tin nhắn do client tự sinh, sắp được theo thời gian
* (ý tưởng từ MercuryLocalIDs.generateOfflineThreadingID của Messenger web).
*
* 63 bit = [ 41 bit Date.now() ][ 22 bit ngẫu nhiên ]
*
* - Sinh ngay khi bấm gửi, không cần hỏi server.
* - Hai OTID sinh ở hai mili-giây khác nhau luôn so được đúng thứ tự thời gian.
* - Server dội tin về kèm lại OTID, client dùng nó để ghép bản "đang gửi" với bản thật.
*/
export const OTID_TIME_BITS = 41;
export const OTID_RANDOM_BITS = 22;
/** Mốc Date.now() làm phần thời gian vượt 41 bit: 2^41 ms ≈ 2039-09-07 15:47 UTC. */
export const OTID_TIME_LIMIT = 2 ** OTID_TIME_BITS;
const RANDOM_MASK = (1n << BigInt(OTID_RANDOM_BITS)) - 1n;
const ID_MASK = (1n << 63n) - 1n;
function random22(): number {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0] & Number(RANDOM_MASK);
}
export interface OtidOptions {
/** mặc định Date.now() */
now?: number;
/** 22 bit ngẫu nhiên, mặc định lấy từ crypto.getRandomValues */
random?: number;
}
/** Sinh OTID dạng chuỗi số thập phân (JSON và server đều nhận chuỗi, không mất chính xác). */
export function generateOtid({ now = Date.now(), random = random22() }: OtidOptions = {}): string {
if (!Number.isInteger(now) || now < 0) throw new RangeError(`now không hợp lệ: ${now}`);
if (now >= OTID_TIME_LIMIT) {
// Bản gốc cắt lấy 63 bit cuối nên tới mốc này ID sẽ quay vòng về số nhỏ.
// Ở đây báo lỗi rõ ràng thay vì âm thầm sinh ID sai thứ tự.
throw new RangeError('Date.now() đã vượt 41 bit, cần đổi định dạng OTID');
}
const id = ((BigInt(now) << BigInt(OTID_RANDOM_BITS)) | (BigInt(random) & RANDOM_MASK)) & ID_MASK;
return id.toString();
}
/** Lấy lại thời điểm sinh (ms) từ một OTID. */
export function otidTime(otid: string): number {
return Number(BigInt(otid) >> BigInt(OTID_RANDOM_BITS));
}
/** So hai OTID theo giá trị số (không so chuỗi: "999" > "1000" theo chuỗi). */
export function compareOtid(a: string, b: string): number {
const x = BigInt(a);
const y = BigInt(b);
return x < y ? -1 : x > y ? 1 : 0;
}
/**
* Bản chép lại thuật toán gốc để so sánh: ghép chuỗi nhị phân, cắt 63 ký tự cuối,
* rồi đổi nhị phân → thập phân bằng phép chia dài thủ công (thời chưa có BigInt).
*/
export function generateOtidFbStyle(now: number, random: number): string {
const randBits = ('0000000000000000000000' + (random >>> 0).toString(2)).slice(-22);
const bits = (now.toString(2) + randBits).slice(-63);
return binaryToDecimal(bits);
}
function binaryToDecimal(bits: string): string {
let out = '';
let rest = bits;
while (rest !== '0') {
let carry = 0;
let quotient = '';
for (let i = 0; i < rest.length; i++) {
carry = 2 * carry + Number(rest[i]);
if (carry >= 10) {
quotient += '1';
carry -= 10;
} else {
quotient += '0';
}
}
out = carry.toString() + out;
rest = quotient.slice(quotient.indexOf('1'));
}
return out;
}
ts
#Đi qua code
BigInt(now) << 22nđẩy thời gian lên bit cao;| (random & 0x3FFFFF)đặt 22 bit ngẫu nhiên xuống bit thấp;& ((1n << 63n) − 1n)giữ 63 bit, khớp với.slice(-63)của bản gốc.- Giới hạn 63 bit (không phải 64) để ID nằm vừa kiểu
int64có dấu ở server (Javalong, MySQLBIGINT) mà không thành số âm. generateOtidFbStylegiữ nguyên thuật toán chuỗi để demo và test so sánh, và để thấy năm 2039 quay vòng thế nào.
#Tại sao không làm cách khác?
- UUID v4: 122 bit ngẫu nhiên, không trùng, nhưng không mang thời gian. Phải lưu thêm
createdAtđể sắp, và ID dài 36 ký tự. - UUID v7 (RFC 9562, 2024): 48 bit thời gian ở đầu + phần ngẫu nhiên, sắp được theo thời gian. Nếu thiết kế mới, đây là lựa chọn chuẩn. OTID gọn hơn (vừa một số 64 bit) và có trước UUID v7 nhiều năm.
- Bộ đếm tăng dần ở client: hai tab hoặc hai thiết bị cùng bắt đầu từ 1. Cần thời gian hoặc ngẫu nhiên để không đụng nhau.
- Đợi server cấp ID rồi mới hiện tin: mất cảm giác "gửi tức thì". Optimistic UI là lý do OTID tồn tại.
#Edge case và cạm bẫy
- Đồng hồ máy chạy sai: OTID dùng giờ của client. Máy lệch 5 phút thì OTID lệch 5 phút. Dùng để ghép và sắp tin của chính client thì không sao; đừng dùng OTID làm thời gian hiển thị hay sắp tin giữa nhiều người. Facebook dùng timestamp của server cho việc đó.
- Đồng hồ chạy lùi (đồng bộ NTP): tin gửi sau có thể có OTID nhỏ hơn tin trước vài mili-giây. Nếu cần thứ tự tuyệt đối trong một client, giữ
max(lastTime, Date.now()). - JSON: luôn gửi và nhận OTID dạng chuỗi.
JSON.parse('{"id": 7520940664699190245}')cho ra7520940664699190000. - Khoá React: dùng OTID làm
keycho tin đang gửi, và giữ nguyên key đó sau khi có ID server. Đổi key giữa chừng làm React unmount rồi mount lại dòng tin (mất animation, mất focus).
#Dùng trong dự án
import { compareOtid, generateOtid } from './otid';
function sendMessage(text: string) {
const otid = generateOtid();
store.addLocal({ key: otid, otid, text, status: 'sending' });
api.send({ text, offline_threading_id: otid });
}
realtime.on('new_message', (msg) => {
// msg.offline_threading_id có mặt nếu là tin của chính client này
const local = msg.offline_threading_id && store.findLocal(msg.offline_threading_id);
if (local) store.replace(local.key, { ...msg, key: local.key, status: 'sent' });
else store.add({ ...msg, key: msg.id });
});
const pending = store.localMessages().sort((a, b) => compareOtid(a.otid, b.otid));
ts
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { compareOtid, generateOtid, generateOtidFbStyle, otidTime, OTID_TIME_LIMIT } from './otid';
describe('OTID', () => {
it('đọc lại được thời điểm sinh', () => {
const now = Date.UTC(2026, 9, 27, 10, 0, 0);
expect(otidTime(generateOtid({ now }))).toBe(now);
});
it('OTID sinh sau luôn lớn hơn, kể cả khi phần ngẫu nhiên nhỏ hơn', () => {
const a = generateOtid({ now: 1_000_000, random: 4_194_303 });
const b = generateOtid({ now: 1_000_001, random: 0 });
expect(compareOtid(a, b)).toBe(-1);
});
it('khớp từng chữ số với thuật toán chuỗi nhị phân của bản gốc', () => {
for (let i = 0; i < 1000; i++) {
const now = 1_500_000_000_000 + Math.floor(Math.random() * 6e11);
const random = Math.floor(Math.random() * 2 ** 22);
expect(generateOtid({ now, random })).toBe(generateOtidFbStyle(now, random));
}
});
it('bản gốc quay vòng khi Date.now() vượt 41 bit, bản viết lại báo lỗi', () => {
const before = generateOtidFbStyle(OTID_TIME_LIMIT - 1, 0);
const after = generateOtidFbStyle(OTID_TIME_LIMIT + 1, 0);
expect(BigInt(after) < BigInt(before)).toBe(true);
expect(() => generateOtid({ now: OTID_TIME_LIMIT })).toThrow(RangeError);
});
it('so theo giá trị số, không theo chuỗi', () => {
expect(['1000', '999'].sort(compareOtid)).toEqual(['999', '1000']);
});
});
ts
#So với thư viện và chuẩn web
- UUID v7 (RFC 9562):
crypto.randomUUID()hiện chỉ sinh v4; cần thư viện (ví dụ góiuuidcóv7()) để sinh v7 (tính tới 10/2026). - ULID: 48 bit thời gian + 80 bit ngẫu nhiên, mã hoá Crockford base32 26 ký tự, sắp được theo chuỗi. Hợp khi ID phải là chuỗi sắp được trực tiếp.
- Snowflake (Twitter, Discord, Instagram dùng biến thể): thêm ID máy và bộ đếm, dành cho server sinh ID với tốc độ cao. Client không cần.
BigIntcó ở mọi trình duyệt chính từ khoảng 2020;JSON.stringifykhông tự serialize BigInt, nên vẫn phải đổi sang chuỗi khi gửi.
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
- #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 (bài này)
- #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

