Worker Lifecycle, Ownership, Termination, and Cleanup
Learn Multiple Tab Orchestration and Web Worker In Action - Part 018
Worker lifecycle and ownership in production: creation, readiness, active execution, cancellation, draining, termination, crash handling, restart policy, page lifecycle integration, ownership invariants, and deterministic cleanup.
Part 018 — Worker Lifecycle, Ownership, Termination, and Cleanup
Target part ini: membuat lifecycle Dedicated Worker eksplisit, deterministic, dan recoverable. Kita akan mendesain state machine worker, ownership contract, startup handshake, active execution, graceful drain, hard termination, crash recovery, cancellation propagation, cleanup, dan testing.
Part 017 membangun arsitektur Dedicated Worker.
Sekarang kita fokus pada satu hal yang sering diremehkan:
Worker lifecycle is product behavior.
Jika lifecycle buruk, aplikasi akan punya:
- memory leak,
- stale task,
- duplicate worker,
- zombie pending promise,
- UI yang menunggu selamanya,
- hasil lama menimpa hasil baru,
- worker mati tanpa diketahui,
- restart loop,
- tab freeze yang merusak asumsi,
- deploy mismatch yang sulit direproduksi.
Lifecycle bukan cleanup kecil di useEffect.
Lifecycle adalah kontrak kepemilikan resource asynchronous.
1. Lifecycle sebagai State Machine
Kita mulai dari state machine.
Minimal states:
| State | Meaning |
|---|---|
idle | client object exists, worker not spawned |
starting | worker constructed, waiting READY |
ready | worker initialized and can accept request |
active | one or more tasks in-flight |
draining | no new work accepted; pending work finishing/cancelling |
failed | worker unusable due to error/startup failure |
terminated | worker explicitly stopped; no more work |
Jangan hanya punya boolean isReady.
Boolean tidak cukup membedakan:
- belum mulai,
- sedang startup,
- startup timeout,
- ready,
- draining,
- failed,
- terminated.
2. Ownership Contract
Worker harus dimiliki oleh satu owner.
Owner bertanggung jawab atas:
- kapan worker dibuat,
- kapan worker siap,
- siapa boleh mengirim request,
- apa batas concurrency,
- apa timeout default,
- apa shutdown policy,
- apa restart policy,
- bagaimana pending request dibatalkan,
- bagaimana metrics dikirim,
- bagaimana resource dibersihkan.
Contract:
export interface WorkerOwner {
start(): Promise<void>;
execute<T>(command: WorkerCommand, options?: WorkerCallOptions): Promise<T>;
shutdown(options?: WorkerShutdownOptions): Promise<void>;
getState(): WorkerRuntimeState;
getStats(): WorkerRuntimeStats;
}
State:
type WorkerRuntimeState =
| 'idle'
| 'starting'
| 'ready'
| 'active'
| 'draining'
| 'failed'
| 'terminated';
Shutdown options:
type WorkerShutdownOptions = {
mode: 'graceful' | 'immediate';
reason: string;
drainTimeoutMs?: number;
};
Ownership Invariant
Only the owner can call
terminate(). Feature code can request shutdown, but must not directly kill the worker.
Kenapa?
Karena terminate() langsung menghentikan worker dan tidak memberi kesempatan operasi berjalan selesai.
Kalau sembarang layer memanggil terminate(), pending promise di layer lain bisa menggantung atau gagal tanpa context.
3. Creation Policy
Ada beberapa pola pembuatan worker.
3.1 Eager Singleton
Worker dibuat saat app start.
const workerRuntime = createSearchWorkerRuntime();
await workerRuntime.start();
Cocok jika:
- worker pasti dipakai,
- startup cost ingin dibayar awal,
- fitur core,
- latency pertama penting.
Risiko:
- menambah startup cost app,
- memori terpakai walau fitur belum digunakan,
- worker chunk harus load awal.
3.2 Lazy Singleton
Worker dibuat saat request pertama.
async function execute(command: WorkerCommand) {
await ensureStarted();
return runtime.execute(command);
}
Cocok untuk fitur berat yang tidak selalu dipakai.
Risiko:
- request pertama lebih lambat,
- race start jika banyak caller bersamaan,
- startup failure terjadi di tengah user action.
Butuh single-flight start:
let startPromise: Promise<void> | null = null;
function ensureStarted() {
if (state === 'ready' || state === 'active') return Promise.resolve();
if (startPromise) return startPromise;
startPromise = start().finally(() => {
startPromise = null;
});
return startPromise;
}
3.3 Feature-Scoped Worker
Worker hidup selama feature/route aktif.
Cocok jika:
- state worker mahal,
- fitur isolated,
- user sering masuk/keluar area,
- cleanup penting.
Risiko:
- repeated startup,
- stale response saat route unmount,
- harus drain/cancel on unmount.
3.4 Ephemeral Worker
Worker dibuat untuk satu job lalu diterminasi.
Cocok jika:
- job jarang,
- job sangat berat,
- ingin memory release cepat,
- startup cost acceptable,
- isolation lebih penting dari reuse.
Risiko:
- startup overhead per job,
- tidak cocok untuk interactive low-latency operations,
- lebih banyak complexity bila banyak job.
4. Startup Lifecycle
Startup harus punya fase.
Runtime start:
async function start(): Promise<void> {
assertState(['idle', 'failed']);
state = 'starting';
generation++;
const currentGeneration = generation;
const worker = new Worker(scriptUrl, {
type: 'module',
name: `${name}-g${currentGeneration}`,
});
currentWorker = worker;
attachWorkerListeners(worker, currentGeneration);
try {
const ready = await performHandshake(worker, currentGeneration, startupTimeoutMs);
if (generation !== currentGeneration) {
worker.terminate();
throw new Error('Worker startup superseded by newer generation');
}
capabilities = ready.capabilities;
state = 'ready';
} catch (error) {
state = 'failed';
worker.terminate();
throw error;
}
}
Generation Token
generation mencegah event dari worker lama memengaruhi runtime baru.
Tanpa generation token:
- worker lama bisa mengirim READY setelah worker baru dibuat,
- response lama bisa resolve request baru,
- error dari worker lama bisa mematikan runtime baru.
Ingat:
In browser orchestration, stale async signals are normal. Every long-lived runtime needs generation guard.
5. Readiness Is Not Construction
Constructor berhasil bukan readiness.
Readiness membutuhkan:
- worker file loaded,
- imports berhasil,
- global init berhasil,
- handler registry siap,
- protocol cocok,
- capability cocok,
- runtime mengirim READY.
Handshake timeout:
function performHandshake(worker: Worker, generation: number, timeoutMs: number) {
return new Promise<ReadyMessage>((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
cleanup();
reject(new Error('Worker handshake timeout'));
}, timeoutMs);
const onMessage = (event: MessageEvent) => {
const msg = event.data;
if (msg?.type !== 'CONTROL.READY') return;
if (generation !== currentGeneration()) return;
cleanup();
resolve(msg);
};
const onError = (event: ErrorEvent) => {
cleanup();
reject(new Error(`Worker startup error: ${event.message}`));
};
const cleanup = () => {
clearTimeout(timeoutId);
worker.removeEventListener('message', onMessage);
worker.removeEventListener('error', onError);
};
worker.addEventListener('message', onMessage);
worker.addEventListener('error', onError);
worker.postMessage({
type: 'CONTROL.HELLO',
protocolVersion: 1,
generation,
createdAt: Date.now(),
});
});
}
6. Active Execution Lifecycle
Saat request diterima:
Runtime harus track pending:
type PendingRequest = {
correlationId: string;
commandType: string;
generation: number;
createdAt: number;
deadlineAt: number;
timeoutId: number;
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
abort?: () => void;
};
const pending = new Map<string, PendingRequest>();
On send:
function markActive() {
if (state === 'ready') state = 'active';
}
function maybeReady() {
if (state === 'active' && pending.size === 0) state = 'ready';
}
But do not use state alone for correctness.
Use pending map and generation.
7. Request Admission Policy
Runtime should reject new requests when not allowed.
| State | Accept new request? |
|---|---|
idle | Maybe, if lazy start allowed |
starting | Queue or wait, policy-specific |
ready | Yes |
active | Yes if under limit |
draining | No |
failed | Maybe restart, policy-specific |
terminated | No |
Admission code:
function assertCanAcceptRequest() {
if (state === 'draining') {
throw new Error('Worker is draining and does not accept new requests');
}
if (state === 'terminated') {
throw new Error('Worker is terminated');
}
if (state === 'failed' && !restartPolicy.enabled) {
throw new Error('Worker failed and restart is disabled');
}
if (pending.size >= maxInFlight) {
throw new Error('Worker backpressure limit reached');
}
}
Alternative: queue instead of reject.
But queue must be bounded.
8. Graceful Shutdown vs Immediate Termination
There are two shutdown types.
Graceful Shutdown
- stop accepting new work,
- optionally send cancel to background tasks,
- wait for pending tasks to finish or timeout,
- terminate worker,
- cleanup listeners/pending maps.
Immediate Termination
- reject all pending promises,
- call
worker.terminate()immediately, - cleanup runtime.
terminate() is hard stop.
It does not give worker a chance to finish operations.
Use immediate termination for:
- page unload-ish cleanup,
- security/session boundary change,
- worker crash containment,
- memory emergency,
- feature forced reset,
- test teardown.
Use graceful shutdown for:
- feature unmount with non-critical background work,
- app route change where result still useful,
- worker replacement/deploy upgrade,
- controlled reset.
9. Graceful Drain Implementation
async function shutdown(options: WorkerShutdownOptions) {
if (state === 'terminated') return;
const worker = currentWorker;
if (!worker) {
state = 'terminated';
return;
}
state = 'draining';
if (options.mode === 'immediate') {
failAllPending(new Error(`Worker terminated: ${options.reason}`));
cleanupWorker(worker);
worker.terminate();
state = 'terminated';
return;
}
worker.postMessage({
type: 'CONTROL.DRAIN',
reason: options.reason,
deadlineAt: Date.now() + (options.drainTimeoutMs ?? 5000),
});
try {
await waitForPendingToDrain(options.drainTimeoutMs ?? 5000);
} finally {
failAllPending(new Error(`Worker shutdown after drain: ${options.reason}`));
cleanupWorker(worker);
worker.terminate();
state = 'terminated';
}
}
Wait drain:
function waitForPendingToDrain(timeoutMs: number): Promise<void> {
if (pending.size === 0) return Promise.resolve();
return new Promise(resolve => {
const intervalId = window.setInterval(() => {
if (pending.size === 0) {
clearInterval(intervalId);
clearTimeout(timeoutId);
resolve();
}
}, 25);
const timeoutId = window.setTimeout(() => {
clearInterval(intervalId);
resolve();
}, timeoutMs);
});
}
This is basic.
A more robust runtime emits drain state and supports cancellation by priority.
10. Worker-side Drain
Inside worker:
let draining = false;
self.addEventListener('message', event => {
const msg = event.data;
if (msg?.type === 'CONTROL.DRAIN') {
draining = true;
for (const [id, controller] of controllers) {
// optional policy: cancel background tasks only
controller.abort('worker-draining');
}
self.postMessage({
type: 'EVENT',
eventType: 'WORKER.DRAINING',
payload: { activeTasks: controllers.size },
});
return;
}
if (msg?.type === 'REQUEST' && draining) {
self.postMessage({
type: 'RESPONSE',
correlationId: msg.correlationId,
ok: false,
error: {
name: 'WorkerDrainingError',
message: 'Worker is draining and refuses new work',
retryable: true,
},
});
return;
}
});
Worker-side drain is cooperative.
Main thread still owns final termination.
11. Cleanup Checklist
When worker is terminated or failed, cleanup must include:
- reject pending promises,
- clear timeout timers,
- remove event listeners,
- clear start promise,
- clear heartbeat timer,
- clear queue,
- clear capability cache,
- mark state,
- invalidate generation,
- release references to worker object,
- emit metric/log.
Client cleanup:
function cleanupWorker(worker: Worker) {
worker.removeEventListener('message', onWorkerMessage);
worker.removeEventListener('messageerror', onWorkerMessageError);
worker.removeEventListener('error', onWorkerError);
currentWorker = null;
capabilities = [];
startPromise = null;
}
Pending cleanup:
function failAllPending(error: Error) {
for (const request of pending.values()) {
clearTimeout(request.timeoutId);
request.reject(error);
}
pending.clear();
}
Cleanup Invariant
After termination, there must be no live timeout, no live event listener, no pending promise, and no strong reference to the worker.
12. Crash Handling
Worker can fail via:
- uncaught error,
- startup import failure,
- syntax error,
- resource/memory failure,
- explicit
self.close()inside worker, terminate()from owner,- browser process crash/discard behavior,
- deployment chunk mismatch.
Main side must listen:
worker.addEventListener('error', onWorkerError);
worker.addEventListener('messageerror', onWorkerMessageError);
Handler:
function onWorkerError(event: ErrorEvent) {
const error = new Error(`Worker error: ${event.message}`);
transitionToFailed(error);
}
function onWorkerMessageError() {
const error = new Error('Worker message deserialization failed');
transitionToFailed(error);
}
function transitionToFailed(error: Error) {
const worker = currentWorker;
state = 'failed';
generation++;
failAllPending(error);
if (worker) {
cleanupWorker(worker);
worker.terminate();
}
maybeRestart(error);
}
Be careful with restart.
Restart can make things worse if failure is deterministic.
13. Restart Policy
Restart policy should be explicit.
type RestartPolicy = {
enabled: boolean;
maxRestarts: number;
windowMs: number;
backoffMs: (attempt: number) => number;
retryableErrors: string[];
};
Example:
const restartPolicy: RestartPolicy = {
enabled: true,
maxRestarts: 3,
windowMs: 60_000,
backoffMs: attempt => Math.min(1000 * 2 ** attempt, 10_000),
retryableErrors: ['WorkerCrashedError', 'StartupTimeoutError'],
};
Restart implementation idea:
async function maybeRestart(error: Error) {
if (!restartPolicy.enabled) return;
if (!isRetryable(error)) return;
const recent = restartHistory.filter(t => Date.now() - t < restartPolicy.windowMs);
if (recent.length >= restartPolicy.maxRestarts) {
state = 'failed';
return;
}
restartHistory = recent.concat(Date.now());
const attempt = recent.length + 1;
const delay = restartPolicy.backoffMs(attempt);
await sleep(delay);
if (state !== 'failed') return;
await start();
}
Restart Invariant
Do not retry deterministic protocol/schema failures blindly.
Examples not good for restart:
- unknown command due to code mismatch,
- validation failure,
- CSP blocking worker script,
- permanent import path broken,
- unsupported browser feature.
Examples maybe okay:
- transient startup timeout,
- worker crashed due to one poison task and task is not replayed,
- memory pressure after cleanup,
- old worker generation replaced.
14. Poison Task Handling
A poison task is input that repeatedly kills or breaks the worker.
If runtime restarts and automatically replays pending work, poison task can cause restart loop.
Therefore:
- do not replay side-effectful tasks by default,
- do not auto-replay unknown failing payload,
- attach idempotency key if replay allowed,
- limit attempts per task,
- mark task as poison after repeated crash association,
- surface actionable error.
Poison guard:
type TaskAttemptRecord = {
commandType: string;
idempotencyKey?: string;
payloadHash?: string;
attempts: number;
};
If same payload crashes worker twice:
throw new Error('Task suspected poison: not replaying after worker restart');
15. terminate() vs Worker close()
There are two common stop directions:
- owner calls
worker.terminate()from outside, - worker calls
self.close()from inside.
Use worker.terminate() when owner decides lifecycle.
Use self.close() only when worker owns completion of its own ephemeral job and protocol expects it.
In long-lived worker runtime, avoid random self.close() inside domain handler.
Bad:
handlers.set('SEARCH.RESET', () => {
self.close();
});
Better:
handlers.set('SEARCH.RESET', () => {
searchIndex.reset();
return { ok: true };
});
Rule
Worker shutdown belongs to lifecycle layer, not domain handler.
16. Page Lifecycle Integration
Owner lives in a page/tab.
The page can become:
- visible,
- hidden,
- frozen,
- restored,
- navigated away,
- discarded by browser,
- bfcache-restored.
Worker lifecycle should react carefully.
Common policy:
| Page event | Worker policy |
|---|---|
| visible | allow interactive work |
| hidden | continue important bounded work, reduce diagnostics |
| freeze/pagehide | checkpoint/cancel non-critical work |
| beforeunload | do not rely on async cleanup |
| pageshow persisted | revalidate runtime state |
| tenant/session change | immediate shutdown/reset |
Example:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
workerRuntime.reducePriority?.('page-hidden');
}
});
window.addEventListener('pagehide', event => {
workerRuntime.shutdown({
mode: 'immediate',
reason: event.persisted ? 'pagehide-bfcache' : 'pagehide-navigation',
});
});
window.addEventListener('pageshow', event => {
if (event.persisted) {
void workerRuntime.start();
}
});
Caveat:
Do not overfit.
Some apps should keep worker alive while hidden if work is user-expected.
Example: local encryption of a file user just selected.
But even then, bounded deadline and cancellation should exist.
17. Session and Security Boundary
When user logs out, tenant changes, or privileges change:
- cancel pending tasks,
- terminate worker,
- clear worker-local state,
- clear sensitive cached data,
- invalidate client generation,
- create new worker only after new session context.
Do not reuse worker across security boundary unless designed.
Bad:
currentTenantId = nextTenantId;
// same worker still has previous tenant index in memory
Better:
await searchWorker.shutdown({
mode: 'immediate',
reason: 'tenant-changed',
});
searchWorker = createSearchWorkerRuntime({ tenantId: nextTenantId });
await searchWorker.start();
Security Invariant
Worker-local memory must not survive a session/tenant boundary unless explicitly allowed and partitioned.
18. HMR, Deploy, and Version Skew
In development, HMR can replace modules while worker script remains old.
In production, old tabs can talk to old/new chunks depending cache and service worker behavior.
Protect with:
- build ID in handshake,
- protocol version,
- capabilities,
- generation token,
- unknown command handling,
- graceful worker replacement.
Handshake:
type ReadyMessage = {
type: 'CONTROL.READY';
workerId: string;
buildId: string;
protocolVersion: number;
capabilities: string[];
};
Mismatch policy:
if (ready.protocolVersion !== CLIENT_PROTOCOL_VERSION) {
await shutdown({ mode: 'immediate', reason: 'protocol-mismatch' });
throw new Error('Worker protocol mismatch. Please reload the page.');
}
Or capability fallback:
if (!ready.capabilities.includes('search:query:v2')) {
useQueryV1Adapter();
}
19. Memory Management
Workers can leak memory too.
Common sources:
- pending maps not cleared,
- large ArrayBuffers retained,
- index cache never reset,
- log buffer unbounded,
- queued tasks unbounded,
- closures retaining payload,
- duplicate workers after route remount,
- old worker references retained by event listeners,
- message history in dev tooling,
- accidental global caches.
Worker memory policy:
type MemoryPolicy = {
maxIndexBytes: number;
maxQueuedTasks: number;
maxLogEntries: number;
resetOnHiddenAfterMs?: number;
};
Stats:
handlers.set('DIAGNOSTICS.GET_STATS', () => ({
activeTasks: controllers.size,
queuedTasks: taskQueue.length,
indexEstimatedBytes: index.estimatedMemoryBytes(),
logEntries: logBuffer.length,
}));
Reset command:
handlers.set('MEMORY.TRIM', () => {
logBuffer.clear();
index.trimCaches();
return { ok: true };
});
Rule
Long-lived worker state must have an eviction or reset story.
20. Timer Cleanup
Timers exist on both sides.
Main thread timers:
- startup timeout,
- request timeout,
- heartbeat timeout,
- drain timeout,
- restart backoff.
Worker timers:
- heartbeat interval,
- scheduler yield timers,
- polling loops,
- diagnostics flush timers,
- internal timeouts.
Every timer needs owner and cleanup.
Bad:
setInterval(sendHeartbeat, 5000);
Better:
let heartbeatId: number | undefined;
function startHeartbeat() {
heartbeatId = setInterval(sendHeartbeat, 5000) as unknown as number;
}
function stopHeartbeat() {
if (heartbeatId !== undefined) {
clearInterval(heartbeatId);
heartbeatId = undefined;
}
}
On worker drain:
stopHeartbeat();
On main cleanup:
clearTimeout(startupTimeoutId);
for (const request of pending.values()) clearTimeout(request.timeoutId);
21. Runtime Implementation: A Lifecycle-Aware Client
This is a compact but meaningful runtime skeleton.
type WorkerRuntimeOptions = {
name: string;
scriptUrl: URL;
startupTimeoutMs: number;
defaultRequestTimeoutMs: number;
maxInFlight: number;
protocolVersion: number;
};
export class DedicatedWorkerRuntime {
private worker: Worker | null = null;
private state: WorkerRuntimeState = 'idle';
private generation = 0;
private startPromise: Promise<void> | null = null;
private pending = new Map<string, PendingRequest>();
private capabilities: string[] = [];
constructor(private readonly options: WorkerRuntimeOptions) {}
getState() {
return this.state;
}
async start(): Promise<void> {
if (this.state === 'ready' || this.state === 'active') return;
if (this.startPromise) return this.startPromise;
if (this.state === 'terminated') throw new Error('Runtime already terminated');
this.startPromise = this.startInternal().finally(() => {
this.startPromise = null;
});
return this.startPromise;
}
async execute<T>(commandType: string, payload: unknown, options: WorkerCallOptions = {}): Promise<T> {
if (this.state === 'idle' || this.state === 'failed') {
await this.start();
}
this.assertCanAcceptRequest();
const worker = this.worker;
if (!worker) throw new Error('Worker not available');
const correlationId = crypto.randomUUID();
const generation = this.generation;
const timeoutMs = options.timeoutMs ?? this.options.defaultRequestTimeoutMs;
const deadlineAt = Date.now() + timeoutMs;
return new Promise<T>((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
this.pending.delete(correlationId);
reject(new Error(`Worker request timeout: ${commandType}`));
this.maybeTransitionToReady();
}, timeoutMs);
this.pending.set(correlationId, {
correlationId,
commandType,
generation,
createdAt: Date.now(),
deadlineAt,
timeoutId,
resolve: resolve as (value: unknown) => void,
reject,
});
if (this.state === 'ready') this.state = 'active';
options.signal?.addEventListener('abort', () => {
const req = this.pending.get(correlationId);
if (!req) return;
this.pending.delete(correlationId);
clearTimeout(req.timeoutId);
worker.postMessage({
type: 'CONTROL.CANCEL',
correlationId,
reason: options.signal?.reason ?? 'aborted',
});
reject(new DOMException('Worker request aborted', 'AbortError'));
this.maybeTransitionToReady();
}, { once: true });
worker.postMessage({
type: 'REQUEST',
protocolVersion: this.options.protocolVersion,
commandType,
correlationId,
generation,
createdAt: Date.now(),
deadlineAt,
payload,
});
});
}
async shutdown(options: WorkerShutdownOptions = { mode: 'immediate', reason: 'shutdown' }) {
const worker = this.worker;
if (!worker || this.state === 'terminated') {
this.state = 'terminated';
return;
}
this.state = 'draining';
this.generation++;
if (options.mode === 'graceful') {
worker.postMessage({
type: 'CONTROL.DRAIN',
reason: options.reason,
});
await this.waitForDrain(options.drainTimeoutMs ?? 3000);
}
this.failAllPending(new Error(`Worker shutdown: ${options.reason}`));
this.detach(worker);
worker.terminate();
this.worker = null;
this.capabilities = [];
this.state = 'terminated';
}
private async startInternal() {
this.state = 'starting';
this.generation++;
const generation = this.generation;
const worker = new Worker(this.options.scriptUrl, {
type: 'module',
name: `${this.options.name}-g${generation}`,
});
this.worker = worker;
this.attach(worker, generation);
try {
const ready = await this.handshake(worker, generation);
if (this.generation !== generation) throw new Error('Worker startup superseded');
this.capabilities = ready.capabilities ?? [];
this.state = 'ready';
} catch (error) {
this.state = 'failed';
this.detach(worker);
worker.terminate();
this.worker = null;
throw error;
}
}
private attach(worker: Worker, generation: number) {
worker.addEventListener('message', this.onMessage);
worker.addEventListener('messageerror', this.onMessageError);
worker.addEventListener('error', this.onError);
}
private detach(worker: Worker) {
worker.removeEventListener('message', this.onMessage);
worker.removeEventListener('messageerror', this.onMessageError);
worker.removeEventListener('error', this.onError);
}
private onMessage = (event: MessageEvent) => {
const msg = event.data;
if (msg?.type !== 'RESPONSE') return;
const req = this.pending.get(msg.correlationId);
if (!req) return;
if (req.generation !== this.generation) return;
this.pending.delete(msg.correlationId);
clearTimeout(req.timeoutId);
if (msg.ok) req.resolve(msg.payload);
else req.reject(deserializeWorkerError(msg.error));
this.maybeTransitionToReady();
};
private onMessageError = () => {
this.transitionToFailed(new Error('Worker messageerror'));
};
private onError = (event: ErrorEvent) => {
this.transitionToFailed(new Error(`Worker error: ${event.message}`));
};
private transitionToFailed(error: Error) {
const worker = this.worker;
this.state = 'failed';
this.generation++;
this.failAllPending(error);
if (worker) {
this.detach(worker);
worker.terminate();
}
this.worker = null;
}
private assertCanAcceptRequest() {
if (this.state === 'draining') throw new Error('Worker is draining');
if (this.state === 'terminated') throw new Error('Worker is terminated');
if (this.pending.size >= this.options.maxInFlight) throw new Error('Worker in-flight limit reached');
}
private maybeTransitionToReady() {
if (this.state === 'active' && this.pending.size === 0) {
this.state = 'ready';
}
}
private waitForDrain(timeoutMs: number) {
if (this.pending.size === 0) return Promise.resolve();
return new Promise<void>(resolve => {
const start = performance.now();
const tick = () => {
if (this.pending.size === 0) return resolve();
if (performance.now() - start >= timeoutMs) return resolve();
setTimeout(tick, 25);
};
tick();
});
}
private failAllPending(error: Error) {
for (const req of this.pending.values()) {
clearTimeout(req.timeoutId);
req.reject(error);
}
this.pending.clear();
}
private handshake(worker: Worker, generation: number): Promise<any> {
return new Promise((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
cleanup();
reject(new Error('Worker startup timeout'));
}, this.options.startupTimeoutMs);
const onMessage = (event: MessageEvent) => {
const msg = event.data;
if (msg?.type !== 'CONTROL.READY') return;
if (generation !== this.generation) return;
cleanup();
resolve(msg);
};
const onError = (event: ErrorEvent) => {
cleanup();
reject(new Error(`Worker startup error: ${event.message}`));
};
const cleanup = () => {
clearTimeout(timeoutId);
worker.removeEventListener('message', onMessage);
worker.removeEventListener('error', onError);
};
worker.addEventListener('message', onMessage);
worker.addEventListener('error', onError);
worker.postMessage({
type: 'CONTROL.HELLO',
protocolVersion: this.options.protocolVersion,
generation,
createdAt: Date.now(),
});
});
}
}
This skeleton intentionally keeps retry/restart modest.
Do not add auto-retry until you have idempotency and poison-task policy.
22. Worker-side Lifecycle Runtime
Worker should also have lifecycle state.
type WorkerInternalState = 'booting' | 'ready' | 'draining' | 'closed';
let state: WorkerInternalState = 'booting';
let generation = 0;
const controllers = new Map<string, AbortController>();
self.addEventListener('message', event => {
const msg = event.data;
if (msg?.type === 'CONTROL.HELLO') {
generation = msg.generation;
state = 'ready';
self.postMessage({
type: 'CONTROL.READY',
protocolVersion: msg.protocolVersion,
generation,
capabilities: ['search:query:v1'],
startedAt: Date.now(),
});
return;
}
if (msg?.type === 'CONTROL.DRAIN') {
state = 'draining';
for (const controller of controllers.values()) {
controller.abort('worker-draining');
}
return;
}
if (msg?.type === 'CONTROL.CANCEL') {
controllers.get(msg.correlationId)?.abort(msg.reason);
return;
}
if (msg?.type === 'REQUEST') {
if (state !== 'ready') {
self.postMessage({
type: 'RESPONSE',
correlationId: msg.correlationId,
ok: false,
error: {
name: 'WorkerNotReadyError',
message: `Worker is ${state}`,
retryable: state === 'draining',
},
});
return;
}
void runRequest(msg);
}
});
Worker can close itself after drain if designed:
async function maybeCloseAfterDrain() {
if (state === 'draining' && controllers.size === 0) {
state = 'closed';
self.close();
}
}
But in most browser app runtimes, main owner calls terminate().
23. Cancellation Propagation and Cleanup
Cancellation must clean both sides.
Main:
- remove pending request,
- clear timeout,
- reject promise with AbortError,
- send CANCEL to worker.
Worker:
- abort controller,
- handler exits cooperatively,
- response may be ignored if main already removed pending,
- controller removed in finally.
Worker handler:
async function runRequest(msg: any) {
const controller = new AbortController();
controllers.set(msg.correlationId, controller);
try {
const result = await handler({
signal: controller.signal,
deadlineAt: msg.deadlineAt,
correlationId: msg.correlationId,
}, msg.payload);
self.postMessage({
type: 'RESPONSE',
correlationId: msg.correlationId,
ok: true,
payload: result,
});
} catch (error) {
self.postMessage({
type: 'RESPONSE',
correlationId: msg.correlationId,
ok: false,
error: serializeError(error),
});
} finally {
controllers.delete(msg.correlationId);
await maybeCloseAfterDrain();
}
}
Cancellation Invariant
Cancellation is not complete until both pending map and worker controller map are cleaned.
24. Event Listener Ownership
When using class methods as listeners, use stable references.
Bad:
worker.addEventListener('message', event => this.onMessage(event));
worker.removeEventListener('message', event => this.onMessage(event)); // does not remove
Because those are different functions.
Good:
private onMessage = (event: MessageEvent) => {
// stable method property
};
worker.addEventListener('message', this.onMessage);
worker.removeEventListener('message', this.onMessage);
This matters for cleanup.
Listener leak can keep runtime objects alive.
25. React Integration without Lifecycle Bugs
In React, do not create worker inside render.
Bad:
function Component() {
const worker = new Worker(new URL('./worker.ts', import.meta.url));
return null;
}
Better: module singleton for app-wide worker.
export const searchRuntime = new DedicatedWorkerRuntime({
name: 'search',
scriptUrl: new URL('./search.worker.ts', import.meta.url),
startupTimeoutMs: 3000,
defaultRequestTimeoutMs: 1000,
maxInFlight: 8,
protocolVersion: 1,
});
Feature hook:
export function useSearchRuntime() {
useEffect(() => {
void searchRuntime.start();
return () => {
// do not shutdown if singleton shared by app
// only shutdown feature-scoped runtime here
};
}, []);
return searchRuntime;
}
For feature-scoped runtime:
function SearchRoute() {
const runtimeRef = useRef<DedicatedWorkerRuntime | null>(null);
if (!runtimeRef.current) {
runtimeRef.current = createSearchRuntime();
}
useEffect(() => {
const runtime = runtimeRef.current!;
void runtime.start();
return () => {
void runtime.shutdown({
mode: 'immediate',
reason: 'search-route-unmounted',
});
};
}, []);
return <SearchPage runtime={runtimeRef.current} />;
}
React Strict Mode in development may mount/unmount effects more than once.
Your lifecycle code must tolerate duplicate start/shutdown calls.
26. Worker Lifecycle Testing
Lifecycle needs tests.
Test 1 — Start Single-Flight
it('starts worker once for concurrent start calls', async () => {
const runtime = createRuntimeWithFakeWorker();
const p1 = runtime.start();
const p2 = runtime.start();
fakeWorker.emitReady();
await Promise.all([p1, p2]);
expect(fakeWorkerFactory.createdCount).toBe(1);
});
Test 2 — Startup Timeout
it('fails startup when READY is not received', async () => {
const runtime = createRuntimeWithFakeWorker({ startupTimeoutMs: 1 });
await expect(runtime.start()).rejects.toThrow(/startup timeout/i);
expect(runtime.getState()).toBe('failed');
});
Test 3 — Pending Rejected on Terminate
it('rejects pending requests on shutdown', async () => {
const runtime = await startedRuntime();
const promise = runtime.execute('SLOW_TASK', {});
await runtime.shutdown({ mode: 'immediate', reason: 'test' });
await expect(promise).rejects.toThrow(/shutdown/i);
});
Test 4 — Stale Response Ignored
it('ignores response from old generation', async () => {
const runtime = await startedRuntime();
const promise = runtime.execute('TASK', {});
await runtime.shutdown({ mode: 'immediate', reason: 'replace' });
oldFakeWorker.emitMessage({
type: 'RESPONSE',
correlationId: getCorrelationIdFromOldRequest(),
ok: true,
payload: 'late',
});
await expect(promise).rejects.toThrow(/shutdown/i);
});
Test 5 — Listener Cleanup
it('removes listeners on shutdown', async () => {
const runtime = await startedRuntime();
await runtime.shutdown({ mode: 'immediate', reason: 'test' });
expect(fakeWorker.listenerCount('message')).toBe(0);
});
27. Failure Matrix
| Scenario | Expected behavior |
|---|---|
| worker constructor throws | state failed, caller receives startup error |
| READY never arrives | startup timeout, worker terminated |
| worker sends invalid response | message ignored or protocol error |
messageerror on main | fail runtime, reject pending |
error on worker | fail runtime, reject pending, terminate worker |
| request timeout | reject request, send optional cancel |
| abort signal | reject AbortError, send cancel |
| shutdown immediate | reject all pending, terminate |
| shutdown graceful | drain until pending zero or timeout, terminate |
| old worker sends late response | ignored by generation/pending guard |
| session changes | immediate shutdown and state reset |
| page bfcache restore | revalidate/restart runtime |
28. Production Lifecycle Policy Template
Use this as a template per worker:
# Worker Lifecycle Policy: Search Worker
Owner: SearchRuntime singleton
Creation: lazy on first search or index operation
Startup timeout: 3000ms
Default request timeout: 1000ms for query, 60000ms for index rebuild
Max in-flight: 8
State retained: inverted index, tokenizer cache
State invalidation: tenant change, schema version change, explicit reset
Cancellation: query cancelled when superseded; index rebuild cancellable
Shutdown: immediate on logout/tenant change; graceful on route unmount if feature-scoped
Restart: enabled for transient crash, max 2 per minute, no automatic replay of side-effectful tasks
Observability: startup latency, task latency, timeout, abort, crash, queue length, index stats
Security: no access/refresh tokens sent to worker; only sanitized document text
Compatibility: protocol version 1, capabilities declared on READY
If your team cannot write this policy, the worker should not be considered production-ready.
29. Lifecycle Anti-Patterns
Anti-pattern 1 — Fire-and-forget Worker
const worker = new Worker('/worker.js');
worker.postMessage(data);
No owner, no timeout, no cleanup.
Anti-pattern 2 — isReady Boolean
let isReady = false;
Cannot represent failure, draining, terminated, generation mismatch.
Anti-pattern 3 — No Generation Guard
Late response from old worker can corrupt new runtime.
Anti-pattern 4 — Shutdown Without Rejecting Pending
Pending promises hang forever.
Anti-pattern 5 — Auto-Restart Everything
Restart loop hides deterministic bugs and burns CPU.
Anti-pattern 6 — Worker Survives Tenant Change
Cross-tenant memory leak/security bug.
Anti-pattern 7 — Cleanup Depends on beforeunload
Browser may not give enough time for async cleanup.
Use cleanup opportunistically, but design state to be recoverable.
30. Lifecycle Checklist
[ ] Worker has explicit owner
[ ] Worker lifecycle has state machine
[ ] `start()` is idempotent / single-flight
[ ] Startup has timeout
[ ] READY handshake exists
[ ] Generation token exists
[ ] Request admission policy exists
[ ] Pending map is always cleaned
[ ] Request timeout clears pending
[ ] Abort clears pending and sends cancel
[ ] `error` listener exists
[ ] `messageerror` listener exists
[ ] Failure rejects all pending
[ ] Shutdown has graceful and immediate modes
[ ] `terminate()` is called only by owner/runtime
[ ] Event listeners are removed on cleanup
[ ] Timers are cleared on cleanup
[ ] Restart policy is explicit and bounded
[ ] Poison task replay is prevented
[ ] Session/tenant boundary terminates or resets worker
[ ] Page lifecycle policy exists
[ ] React Strict Mode duplicate mount tolerated
[ ] Tests cover startup timeout, crash, shutdown, stale response
31. What This Part Establishes
Worker lifecycle is not incidental.
It is a correctness boundary.
The durable mental model:
The most important rule:
A worker without an owner is a leak. A worker without a state machine is a race condition. A worker without cleanup is a future incident.
32. Exercises
Exercise 1 — Draw the Lifecycle
For one worker in your app, draw states:
- idle,
- starting,
- ready,
- active,
- draining,
- failed,
- terminated.
Write allowed transitions.
Exercise 2 — Implement Generation Guard
Create fake worker that sends response after shutdown.
Ensure runtime ignores it.
Exercise 3 — Graceful vs Immediate
Implement both shutdown modes.
Test:
- immediate rejects pending instantly,
- graceful waits for completion,
- graceful falls back after timeout.
Exercise 4 — Session Boundary
Simulate tenant change while worker has index in memory.
Ensure worker is terminated and new worker starts with empty state.
Exercise 5 — Startup Race
Call start() 10 times concurrently.
Verify only one worker is created.
33. References
- MDN — Worker: https://developer.mozilla.org/en-US/docs/Web/API/Worker
- MDN — Worker.terminate: https://developer.mozilla.org/en-US/docs/Web/API/Worker/terminate
- MDN — Worker error event: https://developer.mozilla.org/en-US/docs/Web/API/Worker/error_event
- MDN — Worker messageerror event: https://developer.mozilla.org/en-US/docs/Web/API/Worker/messageerror_event
- MDN — DedicatedWorkerGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope
- MDN — DedicatedWorkerGlobalScope.close: https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/close
- MDN — WorkerGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope
- Chrome Developers — Page Lifecycle API: https://developer.chrome.com/docs/web-platform/page-lifecycle-api
34. Next
Part 019 akan membahas module workers dan bundling:
new Worker(new URL(..., import.meta.url)),- classic vs module worker,
- Vite/Webpack/Rollup behavior,
- code splitting,
- CSP,
- CDN/service worker caching,
- HMR edge cases,
- worker source maps,
- deploy mismatch,
- production build verification.
You just completed lesson 18 in build core. 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.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.