ข้ามไปยังเนื้อหา

Cross-Tab Shared State

ลองนึกภาพ dashboard แบบ multi-tab แท็บ A เพิ่ม counter แท็บ B ควรเห็นค่าใหม่โดยไม่ต้อง polling storage event บน localStorage สามารถแจ้งแท็บ B ได้ แต่ส่งเฉพาะค่า string ใหม่ดิบๆ — ไม่มี structured state layer ในตัว

วิธีแก้ปัญหาที่แข็งแกร่งรวม primitive สามตัว:

  1. localStorage (หรือ IndexedDB) — แหล่งความจริงที่คงทน
  2. BroadcastChannel — แจ้งเตือนทันทีไปยังทุกแท็บอื่นเมื่อ state เปลี่ยน
  3. Web Locks — ป้องกันสองแท็บเขียนพร้อมกัน (leader election หรือ write serialisation)

Pattern ที่ง่ายที่สุด: ทุกแท็บที่เขียน state จะ broadcast การเปลี่ยนแปลงด้วย

const ch = new BroadcastChannel('demo:app-state');
async function setState(key, value) {
await navigator.locks.request('demo:state-write', async () => {
localStorage.setItem(key, JSON.stringify(value));
ch.postMessage({ type: 'STATE_UPDATE', key, value });
});
}
// React to changes from other tabs
ch.onmessage = (e) => {
if (e.data.type === 'STATE_UPDATE') {
console.log('Remote update:', e.data.key, '=', e.data.value);
// Update local UI here
}
};

Lock ทำให้แน่ใจว่าหากสองแท็บพยายามเขียนพร้อมกัน จะ queue แทนที่จะ race

สำหรับงานที่ใช้ resource มาก (เช่น แท็บเดียว polling API) ต้องการให้แท็บ “leader” แค่หนึ่งแท็บทำงานและ broadcast ผลลัพธ์ให้ follower Web Locks ทำให้ทำได้ง่าย:

async function runAsLeader() {
// Only one tab can hold 'leader' at a time.
// The lock is held for the entire async block — when this tab closes the lock
// releases and the next queued tab becomes leader.
await navigator.locks.request('demo:leader', async () => {
console.log('I am the leader');
while (true) {
const data = await fetchLatestData();
ch.postMessage({ type: 'DATA_REFRESH', data });
await new Promise((r) => setTimeout(r, 5000));
}
});
}
runAsLeader();

เมื่อ leader tab ปิด browser ปล่อย lock และ callback ของแท็บถัดไปในคิวจะทำงาน — leader handoff อัตโนมัติโดยไม่ต้องเขียน code เพิ่ม

Browser Storage
ตัวเลือกBenefitCost
รวม 3 primitive (localStorage + BroadcastChannel + Web Locks)ได้ shared-state layer ที่ทั้ง persistent, real-time และปลอดภัยจาก race conditionซับซ้อนกว่าการใช้ primitive เดียว ต้องดูแลทั้งสาม concept พร้อมกันและ debug ยากขึ้น
broadcast-on-write โดยไม่มี lockเขียน code ง่าย ไม่ต้องรอ queueเสี่ยง race condition เมื่อสองแท็บเขียนทับกันแบบ overlapping ในเวลาใกล้กัน
leader election ด้วย Web Locksมีแท็บเดียวทำงานหนัก เช่น polling API ประหยัด resource และลด duplicate requestถ้า leader tab ค้างโดยไม่ปล่อย lock แท็บอื่นจะไม่มีวันได้เป็น leader
  • พึ่งพา storage event เพื่อ sync state ภายในแท็บเดียวกับที่เขียนเอง — event นี้ยิงเฉพาะแท็บอื่นเท่านั้น แท็บที่เขียนต้องอัปเดต UI ของตัวเองด้วย code แยกต่างหาก
  • ไม่ wrap read-then-write (check-then-act) ไว้ใน lock callback เดียวกัน — ถ้า read อยู่นอก lock race condition ยังเกิดได้แม้จะใช้ Web Locks อยู่แล้ว
  • ปล่อยให้ leader tab ทำงานหนักโดยไม่มี fallback เมื่อทุกแท็บถูกปิด — leader election ต้องเริ่มใหม่ทุกครั้งที่เปิดแท็บแรก ไม่มีแท็บใด “จำ” สถานะก่อนหน้าไว้ ต้องพึ่ง localStorage เป็นตัวกู้คืน state

💡 ตัวอย่างจากของจริง

Google Docs — sync edit state, cursor position และ presence indicator ข้ามแท็บที่เปิดเอกสารเดียวกันด้วย pattern คล้าย broadcast-on-write

Notion — ใช้ leader-election pattern ให้แท็บเดียวรับผิดชอบ sync กับ server แล้ว broadcast ผลลัพธ์ไปยังแท็บอื่น ลด duplicate network request จากหลายแท็บพร้อมกัน

ทำไมต้อง wrap localStorage write ใน navigator.locks.request เมื่อหลายแท็บอาจเขียน?
ใน leader-election pattern เกิดอะไรขึ้นเมื่อ leader tab ปิด?
Storage API ใดถูกใช้เป็น persistent source of truth ใน broadcast-on-write pattern ข้างต้น?
แท็บที่เรียก BroadcastChannel.postMessage ได้รับ message event ของตัวเองหรือไม่?