Start HereOrdered learning track

Browser Event Loops, Agent Clusters, and Isolation Boundaries

Learn Multiple Tab Orchestration and Web Worker In Action - Part 004

Browser event loops, tasks, microtasks, rendering turns, workers, agent clusters, and how ordering assumptions break multi-context orchestration.

15 min read2990 words
PrevNext
Lesson 0472 lesson track01–13 Start Here
#browser#event-loop#web-worker#agent-cluster+3 more

Part 004 — Browser Event Loops, Agent Clusters, and Isolation Boundaries

Target part ini: Anda bisa memprediksi urutan eksekusi, memahami kenapa message antar context tidak sinkron, kenapa microtask bisa membuat UI starvation, dan kenapa multi-tab orchestration harus didesain seperti sistem asynchronous.

Jika Part 003 menjawab “kenapa Worker ada”, Part 004 menjawab “apa sebenarnya yang berjalan saat browser mengeksekusi banyak context”.

Banyak bug multi-tab dan worker bukan karena API salah, tetapi karena asumsi ordering salah.

Contoh asumsi salah:

postMessage berarti worker langsung menerima pesan.
Promise.then selalu lebih aman daripada setTimeout.
BroadcastChannel message langsung sampai ke semua tab.
Hidden tab tetap menjalankan timer tepat waktu.
Worker bisa diperlakukan seperti function call asynchronous biasa.
Jika A dikirim sebelum B, semua context pasti melihat A sebelum B.

Sebagian bisa benar dalam boundary tertentu. Sebagian tidak. Engineering level tinggi dimulai dari tahu boundary-nya.


1. Event Loop: Model Minimal yang Harus Dipegang

Satu event loop memproses pekerjaan secara berulang.

Model sederhana:

loop:
  ambil satu task
  jalankan task sampai selesai
  jalankan microtask sampai queue kosong
  browser boleh update rendering
  ulangi

Diagram:

Kata kunci: run to completion.

Jika satu task JavaScript mulai berjalan, browser tidak akan menyisipkan task lain di tengah function biasa. Tidak ada preemption seperti thread OS yang bisa menghentikan function Anda kapan saja. JavaScript cooperative: kode Anda harus selesai atau yield.


2. Task vs Microtask

2.1 Task

Task adalah unit besar pekerjaan yang masuk ke event loop.

Contoh sumber task:

  • click event,
  • timer callback,
  • network event,
  • message event dari postMessage,
  • BroadcastChannel message,
  • worker message,
  • parsing script tertentu,
  • lifecycle event.

Task diproses satu per satu.

2.2 Microtask

Microtask adalah continuation yang diproses setelah task saat ini selesai, sebelum browser mengambil task berikutnya dan sebelum rendering opportunity berikutnya.

Contoh sumber microtask:

  • Promise reaction,
  • queueMicrotask,
  • mutation observer callback pada timing tertentu.

Model:

console.log('A');

setTimeout(() => console.log('B task'), 0);

Promise.resolve().then(() => console.log('C microtask'));

console.log('D');

Urutan umum:

A
D
C microtask
B task

Karena Promise continuation masuk microtask, sedangkan setTimeout masuk task berikutnya.


3. Microtask Starvation

Microtask terlihat “cepat” karena jalan sebelum task berikutnya. Tetapi chain microtask panjang bisa menunda rendering dan input.

Buruk:

function spinMicrotasks() {
  Promise.resolve().then(spinMicrotasks);
}

spinMicrotasks();

Ini dapat membuat event loop terus menjalankan microtask checkpoint tanpa memberi kesempatan task lain berjalan.

Versi realistis:

async function processMany(items: Item[]) {
  for (const item of items) {
    await Promise.resolve();
    process(item);
  }
}

Banyak engineer mengira await Promise.resolve() adalah yield ke browser. Ia bukan yield task. Ia hanya menunda ke microtask, dan microtask masih diproses sebelum browser mengambil task berikutnya.

Jika tujuan Anda memberi browser peluang memproses input/rendering, gunakan yield yang benar-benar memberi giliran ke event loop turn lain, misalnya task boundary atau API scheduling yang sesuai.


4. Rendering Opportunity Bukan Janji Rendering

Setelah task dan microtask selesai, browser boleh melakukan rendering update. Tetapi “boleh” bukan “pasti”. Browser dapat menggabungkan, menunda, atau melewati rendering tergantung kondisi.

Model mental:

JavaScript selesai -> microtask selesai -> browser punya kesempatan render

Bukan:

setState -> DOM pasti langsung terlihat

Pada framework modern, ada tambahan scheduler framework di atas event loop browser. React, Vue, Svelte, Solid, dan framework lain punya mekanisme batching/commit berbeda. Tetapi mereka tetap berjalan di atas constraint browser.

Untuk orchestration, implikasinya:

  • jangan mengandalkan “UI sudah render” hanya karena state sudah diset,
  • jangan mengirim message yang mengasumsikan DOM sudah update kecuali Anda punya boundary eksplisit,
  • jangan melakukan heavy synchronous work setelah state update jika user perlu segera melihat feedback.

5. Worker Event Loop

Worker memiliki event loop sendiri. Dedicated Worker tidak berbagi DOM event loop main thread. Inilah alasan ia bisa menjalankan CPU work tanpa memblokir UI.

Namun worker tetap JavaScript event loop:

worker receives message task
runs handler to completion
runs worker microtasks
then handles next worker task

Jika worker menjalankan loop CPU panjang, worker itu sendiri tidak bisa menerima cancel message sampai loop selesai atau sampai kode worker yield/check cancellation.

Jadi worker tidak otomatis membuat cancellation mudah. Ia hanya memindahkan blocking dari main thread ke worker thread.


6. Multiple Event Loops, Multiple Realities

Dalam aplikasi multi-tab, Anda bisa punya:

Tab A main event loop
Tab B main event loop
Tab C main event loop
Dedicated Worker for Tab A
Dedicated Worker for Tab B
SharedWorker event loop
Service Worker event loop

Masing-masing punya queue sendiri, lifecycle sendiri, dan timing sendiri.

Tidak ada satu global event loop aplikasi. Yang ada adalah beberapa local event loop yang berkomunikasi lewat asynchronous messages.

Ini sama seperti distributed systems mini:

  • setiap node punya clock/queue sendiri,
  • message bisa tertunda,
  • node bisa pause/resume,
  • node bisa mati,
  • ordering hanya kuat dalam boundary tertentu,
  • state harus diberi version/fence.

7. Ordering: Apa yang Bisa dan Tidak Bisa Diasumsikan

7.1 Dalam satu call stack

Synchronous code run-to-completion.

state.a = 1;
state.b = 2;

Tidak ada event lain yang masuk di tengah dua assignment itu.

7.2 Dalam satu event loop, task dan microtask

Microtask yang dibuat dalam task saat ini diproses sebelum task berikutnya.

setTimeout(() => console.log('task 1'), 0);
Promise.resolve().then(() => console.log('microtask'));
setTimeout(() => console.log('task 2'), 0);

Umumnya:

microtask
task 1
task 2

7.3 Antara main thread dan worker

postMessage mengirim message asynchronous. Handler worker akan berjalan ketika event loop worker mengambil task message itu.

Tidak berarti:

main postMessage -> worker immediately running now

Yang benar:

main postMessage -> message queued -> worker event loop handles later

7.4 Antara dua tab

Jika Tab A mengirim BroadcastChannel message dan Tab B sedang sibuk, tersembunyi, atau lifecycle-nya berubah, Tab B akan memproses saat event loop-nya mendapat kesempatan. Tidak ada shared synchronous memory hanya karena same origin.

7.5 Antara storage dan message

Misalnya:

localStorage.setItem('token-version', '2');
channel.postMessage({ type: 'token-updated', version: 2 });

Tab lain mungkin menerima signal setelah storage update. Tetapi desain yang baik tetap harus idempotent:

on token-updated(version=2): read current durable state, compare version, apply if newer

Jangan bergantung penuh pada message sebagai state. Message adalah signal. State harus punya sumber kebenaran atau version yang jelas.


8. Agent dan Agent Cluster: Kenapa Shared Memory Tidak Global

Spesifikasi HTML menggunakan konsep agent dan agent cluster untuk memodelkan unit eksekusi dan boundary berbagi memori. Untuk engineering praktis, pahami ini:

agent = execution agent yang menjalankan JavaScript dengan event loop terkait
agent cluster = kelompok agent yang boleh berbagi memory tertentu seperti SharedArrayBuffer dalam kondisi yang memenuhi syarat

Contoh relasi praktis:

  • Window dan dedicated worker yang dibuatnya dapat berada dalam agent cluster yang memungkinkan shared memory tertentu.
  • Worker dapat membuat dedicated worker lain.
  • Tidak semua context same-origin otomatis berarti boleh berbagi memory langsung.
  • Cross-origin isolation diperlukan untuk memakai SharedArrayBuffer secara modern.

Mental model:

same origin       -> boleh pakai beberapa primitive komunikasi/storage
same agent loop   -> ordering lebih lokal
same agent cluster-> potensi shared memory tertentu
same process      -> detail implementasi browser, jangan dijadikan kontrak app

Jangan mencampur istilah ini.

8.1 Same-origin bukan same-event-loop

Dua tab same-origin dapat berbagi BroadcastChannel, storage tertentu, service worker, cache, IndexedDB. Tetapi mereka tidak menjadi satu event loop.

8.2 Same-origin bukan same-lifecycle

Tab A visible, Tab B hidden, Tab C frozen. Same origin tidak menyamakan lifecycle.

8.3 Same-origin bukan same-memory

Tanpa shared memory primitive, data dikirim lewat clone/transfer/message. Jangan menganggap object reference bisa dibagi antar tab.


9. Isolation Boundaries yang Sering Diabaikan

9.1 Realm/global boundary

Window punya window. Worker punya WorkerGlobalScope. Service Worker punya ServiceWorkerGlobalScope. Mereka tidak punya API yang sama.

ContextGlobalDOM accessTypical role
Window/tabWindowyaUI, DOM, user interaction
Dedicated WorkerDedicatedWorkerGlobalScopetidakper-owner compute
SharedWorkerSharedWorkerGlobalScopetidakshared same-origin hub
Service WorkerServiceWorkerGlobalScopetidaknetwork/cache/event proxy

9.2 Lifecycle boundary

Context bisa hilang atau pause. Contoh:

  • tab ditutup,
  • tab hidden lalu frozen,
  • tab discarded lalu reload,
  • worker terminated,
  • service worker stopped saat idle,
  • browser update/restart,
  • mobile OS reclaim memory.

Event loop model tidak cukup tanpa lifecycle model. Part 006 akan fokus penuh ke lifecycle.

9.3 Storage partition boundary

Modern browser privacy model membuat storage dan komunikasi tertentu bisa dipartisi berdasarkan top-level site/origin context. Jangan membuat asumsi “same-origin selalu global di seluruh browser profile” tanpa memahami storage partitioning.

Untuk materi implementasi, prinsip amannya:

Treat communication scope as explicit and test it in target browser matrix.

10. Run-to-Completion dan Atomicity Semu

Karena satu task run-to-completion, engineer sering merasa operasi mereka atomic.

Contoh:

let refreshing = false;

async function refreshTokenIfNeeded() {
  if (refreshing) return;

  refreshing = true;
  await refreshToken();
  refreshing = false;
}

Dalam satu tab, ini bisa mencegah double refresh lokal. Tapi antar tab tidak berguna.

Tab A:

refreshing = false -> true

Tab B:

refreshing = false -> true

Mereka punya memory berbeda.

Atomicity run-to-completion hanya berlaku dalam satu execution context/event loop, bukan lintas tab.

10.1 Untuk cross-tab atomicity, gunakan primitive koordinasi

Misalnya Web Locks:

await navigator.locks.request('auth-refresh', async lock => {
  // only one same-origin participant holding this lock at a time
  await refreshTokenSafely();
});

Atau gunakan durable lease di IndexedDB/localStorage dengan fencing token bila Web Locks tidak tersedia. Detailnya nanti di Part 039–040.


11. Reentrancy: Event Tidak Masuk Tengah Function, Tapi State Bisa Usang Setelah Await

Synchronous code run-to-completion. Tetapi await memecah function menjadi beberapa continuation.

async function saveCase(caseId: string) {
  const snapshot = getCaseState(caseId);

  await api.save(snapshot);

  markSaved(caseId, snapshot.version);
}

Selama await, event lain bisa berjalan:

  • user mengubah case,
  • tab lain mengirim update,
  • token refresh terjadi,
  • route berubah,
  • component unmount,
  • worker result lama masuk.

Jadi setelah await, snapshot bisa usang.

11.1 Version check setelah await

async function saveCase(caseId: string) {
  const snapshot = getCaseState(caseId);
  const versionAtStart = snapshot.version;

  await api.save(snapshot);

  if (getCaseState(caseId).version !== versionAtStart) {
    return; // do not mark latest state as saved by old request
  }

  markSaved(caseId, versionAtStart);
}

Dalam orchestration, hampir semua await adalah boundary reentrancy. Perlakukan seperti distributed async gap.


12. Message Passing is Not a Function Call

Function call:

caller -> callee executes now -> returns/throws

Message passing:

sender -> serialize/clone/transfer -> enqueue -> receiver later -> response maybe later

Konsekuensi:

Function callMessage passing
stack samastack berbeda
error bisa throw langsungerror harus diserialisasi
memory reference samadata cloned/transferred/shared explicitly
ordering lokal kuatordering bergantung channel dan queue
cancellation via control flowcancellation via protocol
timeout jarang perlutimeout wajib dipikirkan

12.1 Envelope wajib

type MessageEnvelope<T> = {
  protocol: string;
  type: string;
  messageId: string;
  correlationId?: string;
  causationId?: string;
  senderId: string;
  issuedAt: number;
  schemaVersion: number;
  payload: T;
};

12.2 Causality chain

Untuk debugging multi-context:

user-click event
  -> command message A
    -> worker task B
      -> BroadcastChannel event C
        -> tab state update D

Gunakan:

  • messageId untuk identitas event,
  • correlationId untuk request flow,
  • causationId untuk parent event,
  • senderId untuk asal context,
  • issuedAt untuk observability, bukan ordering absolut.

13. Ordering Bug: Auth Refresh Race

Naive flow:

Tab A gets 401
Tab A starts refresh
Tab B gets 401
Tab B starts refresh
Tab A success -> broadcasts token v2
Tab B fail -> broadcasts logout

Event loop masing-masing tab valid. Sistem global salah.

Solusi bukan “Promise lebih cepat”. Solusinya ownership protocol:

1. acquire auth-refresh lock
2. re-read current token version
3. if already refreshed, reuse
4. only lock holder calls refresh endpoint
5. publish versioned result
6. receivers apply only newer token version
7. failure from non-owner cannot force logout

Event loop reasoning memberi tahu bahwa local flag tidak cukup.


14. Ordering Bug: Worker Result Setelah Route Berubah

User di route /cases/123
Main thread kirim worker task compute detail 123
User pindah ke /cases/456
Worker selesai task 123
Main thread apply result ke current page
UI menampilkan data salah

Bug ini bukan worker-specific. Ini stale async result.

Solusi:

type RouteScopedRequest = {
  requestId: string;
  routeKey: string;
  entityId: string;
};

function applyWorkerResult(result: {
  requestId: string;
  routeKey: string;
  entityId: string;
  data: unknown;
}) {
  if (result.routeKey !== currentRouteKey()) return;
  if (result.entityId !== currentEntityId()) return;

  applyData(result.data);
}

Boundary event loop berarti “selesai nanti” selalu bisa berarti “terlambat untuk konteks sekarang”.


15. Timer Semantics: Timer Bukan Jam Akurat

setTimeout(fn, 1000) bukan janji bahwa fn pasti berjalan tepat setelah 1000ms. Ia berarti callback eligible untuk masuk queue setelah delay minimum, lalu akan dijalankan saat event loop bisa memprosesnya.

Dalam browser modern:

  • tab hidden bisa mengalami throttling,
  • tab frozen bisa menghentikan task tertentu,
  • main thread busy membuat callback telat,
  • nested timers bisa dibatasi,
  • mobile OS/browser bisa lebih agresif.

Jangan memakai timer lokal sebagai distributed lock tanpa lease/fencing yang benar.

15.1 Timer drift

let count = 0;

setInterval(() => {
  count++;
  doWork();
}, 1000);

Jika doWork lambat atau tab hidden, interval bisa drift atau callback terkumpul tergantung browser behavior. Untuk pekerjaan penting, gunakan timestamp dan checkpoint.

setInterval(() => {
  const now = Date.now();
  runDueJobs(now);
}, 1000);

Lebih baik lagi, untuk multi-tab job:

acquire lock -> read due jobs from durable store -> process bounded batch -> checkpoint

16. Microtask vs Task Yield: Kesalahan Umum

16.1 await Promise.resolve()

await Promise.resolve();

Ini yield ke microtask, bukan memberi browser kesempatan penuh untuk task lain/rendering.

16.2 setTimeout(..., 0)

await new Promise(resolve => setTimeout(resolve, 0));

Ini membuat task boundary baru. Browser punya peluang lebih besar untuk memproses input/rendering sebelum continuation, meskipun detail scheduling tetap browser-dependent.

16.3 requestAnimationFrame

Cocok untuk sinkron dengan rendering frame.

await new Promise<void>(resolve => {
  requestAnimationFrame(() => resolve());
});

16.4 requestIdleCallback

Cocok untuk low-priority work saat browser idle, tetapi jangan digunakan untuk pekerjaan yang harus selesai deterministik cepat tanpa timeout/fallback.

16.5 scheduler.yield() / scheduler.postTask()

API scheduling modern dapat memberi cara lebih eksplisit untuk prioritas/yield, tetapi support browser harus dicek dan fallback disiapkan.

Kita akan bahas scheduling API lebih jauh di Part 063.


17. Worker Message Ordering

Dalam satu Worker dan satu arah komunikasi, message yang dikirim berurutan umumnya diproses sebagai message tasks sesuai antrean channel. Namun desain production jangan berhenti di “umumnya urut”.

Kenapa?

  • task A bisa lebih lama dari task B,
  • response B bisa selesai sebelum A jika worker memproses parallel melalui subworker atau async IO,
  • main thread bisa ignore/apply berdasarkan state saat response datang,
  • worker bisa restart dan kehilangan in-flight state,
  • message dari channel berbeda bisa interleave.

17.1 Jangan pakai arrival order sebagai version

Buruk:

worker.onmessage = e => {
  state.result = e.data;
};

Lebih baik:

worker.onmessage = e => {
  const message = e.data;

  if (message.version < state.version) {
    return;
  }

  state.version = message.version;
  state.result = message.result;
};

17.2 Sequence number per stream

interface StreamMessage<T> {
  streamId: string;
  sequence: number;
  payload: T;
}

Gunakan sequence bila Anda perlu mendeteksi missing/out-of-order message.

const expectedSequenceByStream = new Map<string, number>();

function handleStreamMessage<T>(message: StreamMessage<T>) {
  const expected = expectedSequenceByStream.get(message.streamId) ?? 1;

  if (message.sequence !== expected) {
    requestResync(message.streamId, expected, message.sequence);
    return;
  }

  apply(message.payload);
  expectedSequenceByStream.set(message.streamId, expected + 1);
}

18. Event Loop Turns as Observability Unit

Untuk debugging, log “function X dipanggil” tidak cukup. Anda perlu tahu:

  • context mana,
  • event loop turn mana,
  • message apa yang menyebabkan ini,
  • task masuk dari channel mana,
  • berapa lama queue delay,
  • berapa lama execution,
  • apakah result stale.

18.1 Context identity

const contextId = `${crypto.randomUUID()}`;

const contextInfo = {
  contextId,
  kind: 'window',
  createdAt: performance.now(),
};

18.2 Logical turn counter

Tidak ada API standar sederhana untuk “event loop turn id”. Tapi Anda bisa membuat counter pada entrypoint message/event yang Anda kontrol.

let logicalTurn = 0;

function enterTurn<T>(source: string, fn: () => T): T {
  logicalTurn++;
  const turn = logicalTurn;
  const startedAt = performance.now();

  try {
    return fn();
  } finally {
    const duration = performance.now() - startedAt;
    recordTurn({ source, turn, duration });
  }
}
channel.onmessage = event => {
  enterTurn('broadcast-channel', () => {
    handleBroadcast(event.data);
  });
};

Ini tidak sempurna, tapi membantu causal tracing.


19. Context Identity dan Epoch

Dalam multi-tab, setiap tab perlu identitas.

type ContextKind = 'window' | 'dedicated-worker' | 'shared-worker' | 'service-worker';

interface ContextIdentity {
  contextId: string;
  kind: ContextKind;
  bootId: string;
  startedAt: number;
  pageVisibility?: DocumentVisibilityState;
}

Kenapa bootId penting?

Karena tab bisa reload dengan storage/session yang sama tetapi runtime baru. Worker bisa restart. Service worker bisa update. Identitas runtime harus berbeda dari identitas user/session.

19.1 Epoch untuk leadership

interface LeadershipClaim {
  leaderId: string;
  epoch: number;
  acquiredAt: number;
  expiresAt: number;
}

Jika leader lama resume setelah freeze, ia mungkin masih merasa leader. Epoch/fencing token mencegah stale leader menulis state baru.

function canCommit(claim: LeadershipClaim, currentEpoch: number) {
  return claim.epoch === currentEpoch;
}

20. Agent Cluster dan SharedArrayBuffer: Preview

Shared memory mengubah model dari message passing ke memory coordination.

Dengan SharedArrayBuffer + Atomics, beberapa agent bisa membaca/menulis memory yang sama. Ini kuat, tapi berbahaya jika mental model concurrency lemah.

Message passing:

send immutable-ish snapshot -> receiver processes -> send result

Shared memory:

both sides see same bytes -> need synchronization protocol

Jangan memakai shared memory untuk menghindari desain protocol. Shared memory membutuhkan protocol yang lebih ketat.

Contoh ringkas:

const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 2);
const view = new Int32Array(sab);

Atomics.store(view, 0, 42);
Atomics.notify(view, 0);

Topik ini akan dibahas di Part 057–060. Untuk sekarang cukup pegang:

Shared memory is not shared object convenience.
It is low-level concurrency infrastructure.

21. Service Worker Event Loop: Bukan Daemon Abadi

Service Worker sering disalahpahami sebagai background process yang selalu hidup.

Model yang lebih aman:

Service Worker is event-driven.
It can be started to handle events and stopped when idle.

Konsekuensi:

  • jangan menyimpan critical state hanya di variable global service worker,
  • persist state penting ke IndexedDB/Cache/Storage sesuai kebutuhan,
  • handle event secara idempotent,
  • siap menghadapi restart,
  • jangan menganggap timer panjang di service worker sebagai scheduler utama.

Buruk:

let pendingSyncJobs: Job[] = [];

Lebih baik:

pending jobs live in durable store;
service worker event reads bounded batch;
processes;
checkpoints;
exits safely.

Service worker adalah event handler dengan lifecycle khusus, bukan server kecil yang selalu running.


22. BroadcastChannel Event Loop Semantics

BroadcastChannel berguna untuk same-origin communication antar browsing context dan worker yang berada dalam scope komunikasi yang sesuai. Tetapi ia tetap asynchronous message passing.

Pattern aman:

const channel = new BroadcastChannel('app-events');

channel.postMessage({
  protocol: 'app-events.v1',
  type: 'case-updated',
  messageId: crypto.randomUUID(),
  caseId,
  version,
});

Receiver:

channel.onmessage = event => {
  const message = event.data;

  if (message.protocol !== 'app-events.v1') return;

  if (message.type === 'case-updated') {
    invalidateCaseIfNewer(message.caseId, message.version);
  }
};

Prinsip:

BroadcastChannel carries invalidation/signal.
Durable store or server carries authoritative state.

Jangan mengirim seluruh state besar setiap kali jika receiver bisa fetch/read sendiri berdasarkan version.


23. IndexedDB Callback dan Transaction Boundaries

IndexedDB asynchronous. Ia punya transaction semantics sendiri. Dalam multi-context, Anda perlu paham bahwa:

  • transaction tidak sama dengan global application lock,
  • beberapa tab bisa membuka database,
  • upgrade/migration perlu koordinasi,
  • tab lama bisa menahan koneksi dan menghambat upgrade,
  • callback result datang lewat event loop.

Part 047–056 akan membahas storage detail. Untuk event loop part ini, poinnya:

IndexedDB read result is async; by the time it arrives, app state may have changed.

Jadi tetap perlu version check setelah read.

const requestVersion = currentState.version;
const record = await readCaseFromDb(caseId);

if (currentState.version !== requestVersion) {
  return;
}

applyRecord(record);

24. Practical Rule: Every Async Boundary Needs a Guard

Async boundary:

  • await,
  • postMessage,
  • worker response,
  • BroadcastChannel message,
  • setTimeout,
  • IndexedDB callback,
  • network response,
  • service worker event,
  • visibility change,
  • page restore,
  • lock acquisition.

Guard types:

GuardMelindungi dari
requestIdresponse salah request
versionstale data overwrite
routeKeyresult masuk route salah
contextIdecho/self-message tidak sengaja
epoch/fencing tokenstale leader commit
idempotency keyduplicate effect
deadlineresult terlalu lama
visibility statebackground behavior berbeda
auth/session versionresult dari session lama

Contoh envelope lengkap:

interface OrchestrationMessage<T> {
  protocol: 'orchestrator.v1';
  messageId: string;
  correlationId: string;
  causationId?: string;
  sender: ContextIdentity;
  type: string;
  issuedAt: number;
  deadlineAt?: number;
  sessionVersion: number;
  appVersion: string;
  payloadVersion: number;
  payload: T;
}

Ya, ini terlihat berat. Tetapi untuk platform internal besar, envelope seperti ini sering lebih murah daripada debugging race condition production.


25. Diagram: From User Click to Worker Result

Perhatikan dua hal:

  1. Worker compute tidak terjadi di tengah click handler main thread.
  2. Result worker datang sebagai task baru nanti, sehingga harus divalidasi terhadap state terbaru.

26. Design Pattern: Explicit Turn Boundary

Untuk pekerjaan main thread yang perlu chunking:

export async function yieldToNextTask() {
  await new Promise<void>(resolve => setTimeout(resolve, 0));
}

Pemakaian:

async function normalizeLargeList(records: RawRecord[]) {
  const result: NormalizedRecord[] = [];

  for (let i = 0; i < records.length; i++) {
    result.push(normalize(records[i]));

    if (i % 500 === 0) {
      await yieldToNextTask();
    }
  }

  return result;
}

Tetapi ini bukan pengganti worker untuk CPU-heavy work. Ini fallback untuk pekerjaan yang:

  • tidak terlalu besar,
  • UI-adjacent,
  • perlu akses main-thread state,
  • bisa menerima total latency lebih panjang.

27. Design Pattern: Message Envelope With Apply Guard

interface WorkerResult<T> {
  protocol: 'worker-runtime.v1';
  requestId: string;
  routeKey: string;
  dataVersion: number;
  result: T;
}

function handleWorkerResult<T>(message: WorkerResult<T>) {
  if (message.protocol !== 'worker-runtime.v1') return;
  if (!pendingRequests.has(message.requestId)) return;
  if (message.routeKey !== currentRouteKey()) return;
  if (message.dataVersion < currentDataVersion()) return;

  applyWorkerResult(message.result);
}

Ini simple, tapi menyelesaikan banyak bug.


28. Design Pattern: Causal Broadcast as Invalidation

Sender:

channel.postMessage({
  protocol: 'case-cache.v1',
  type: 'case-invalidated',
  messageId: crypto.randomUUID(),
  caseId,
  version,
  senderId: contextId,
});

Receiver:

channel.onmessage = async event => {
  const message = event.data;

  if (message.senderId === contextId) return;
  if (message.type !== 'case-invalidated') return;

  const local = getLocalCaseVersion(message.caseId);

  if (local >= message.version) return;

  markCaseStale(message.caseId, message.version);
  scheduleRefetch(message.caseId, message.version);
};

Channel tidak membawa full truth. Ia membawa “sesuatu berubah”. Ini mengurangi clone cost dan stale overwrite.


29. Common Failure Modes

29.1 Promise-as-yield bug

for (const item of hugeList) {
  await Promise.resolve();
  process(item);
}

Gejala: UI tetap terasa freeze/lag karena microtask chain tidak memberi ruang cukup untuk task/rendering.

29.2 Local mutex bug

let isLeader = false;

Gejala: setiap tab punya isLeader sendiri. Semua merasa aman.

29.3 Arrival-order bug

state.value = message.value;

Gejala: response lama overwrite state baru.

29.4 Timer-as-lock bug

localStorage.setItem('lock', Date.now() + 5000);

Gejala: tab frozen resume dengan lock usang; clock/timer drift; split brain.

29.5 Service-worker-global-state bug

let authToken = undefined;

Gejala: service worker restart dan state hilang.

29.6 Worker-cancel-too-late bug

cancel message queued but worker is busy in synchronous loop

Gejala: CPU tetap jalan meski UI sudah pindah route/query.


30. Testing Ordering Assumptions

Anda tidak bisa hanya mengetes happy path.

30.1 Delay injection

function randomDelay(maxMs: number) {
  return new Promise(resolve => {
    setTimeout(resolve, Math.floor(Math.random() * maxMs));
  });
}

Inject di boundary:

async function handleWorkerResultWithChaos(message: WorkerResult<unknown>) {
  if (isChaosEnabled()) {
    await randomDelay(500);
  }

  handleWorkerResult(message);
}

30.2 Out-of-order simulation

const results = [resultV2, resultV1];

for (const result of results) {
  handleWorkerResult(result);
}

expect(currentVersion()).toBe(2);

30.3 Frozen/resume simulation

Di browser automation, simulasikan:

  • buka dua tab,
  • tab A acquire leadership,
  • pause/tab hidden,
  • tab B acquire leadership setelah timeout,
  • tab A resume,
  • pastikan Tab A tidak commit dengan epoch lama.

Kita akan bahas testing multi-tab di Part 067 dan chaos testing di Part 068.


31. Engineering Checklist

Saat melihat async/browser orchestration code, cek:

  • Apakah ada asumsi synchronous pada postMessage?
  • Apakah Promise dipakai sebagai yield padahal butuh task boundary?
  • Apakah worker result punya request ID?
  • Apakah response stale dicegah dengan version/route/session guard?
  • Apakah BroadcastChannel message membawa sender ID?
  • Apakah message dianggap state, bukan signal?
  • Apakah local boolean dipakai untuk cross-tab lock?
  • Apakah timer dipakai sebagai clock akurat?
  • Apakah service worker menyimpan state penting hanya di memory?
  • Apakah cancellation bisa diproses saat worker busy?
  • Apakah context identity dibedakan dari user/session identity?
  • Apakah setiap async boundary punya failure/timeout path?

32. Mental Model Akhir

Pegang model ini:

A browser app is a set of event-loop-driven actors.
Actors do not share call stacks.
Actors communicate by messages, storage, locks, and explicit shared memory.
Every async boundary can reorder reality.
Every result must prove it is still valid before mutating state.

Kalau Part 003 mengajarkan “main thread harus dilindungi”, Part 004 mengajarkan “ordering harus dibuktikan”.


33. Ringkasan

Event loop adalah unit dasar eksekusi browser. Task berjalan run-to-completion. Microtask berjalan setelah task dan bisa menyebabkan starvation bila disalahgunakan. Rendering terjadi pada opportunity tertentu, bukan sebagai efek langsung setiap state update. Worker punya event loop sendiri, tetapi tetap asynchronous dan cooperative. Multi-tab berarti banyak event loop dengan lifecycle berbeda.

Konsekuensi arsitekturalnya jelas:

  1. Jangan menganggap message passing sebagai function call.
  2. Jangan memakai local flag untuk koordinasi lintas tab.
  3. Jangan mengandalkan arrival order sebagai kebenaran.
  4. Jangan memakai Promise microtask sebagai yield UI universal.
  5. Jangan menyimpan state penting di memory context yang bisa mati.
  6. Selalu guard result dengan request ID, version, route/session, dan fencing token bila perlu.

Part berikutnya akan masuk ke origin/security model, karena seluruh primitive komunikasi multi-tab dan worker dibatasi oleh origin, storage partition, isolation policy, dan permission surface browser.


Referensi

Lesson Recap

You just completed lesson 04 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.