---
url: 'https://adk.nht.io/assembly/batteries-isolation.md'
description: >-
  The full spec/host/guest option and exception surface for running heavy work
  off-thread (Web Worker) or out-of-process (node child_process).
---

# Isolation batteries

## LLM summary — Isolation batteries

* Barrel `@nhtio/adk/batteries/isolation` (browser-safe, re-exported from the batteries aggregate) ships:
  the spec DSL (`defineIsolatedService`/`method`/`stream`/`event`), the guest server
  (`serveIsolated`/`serveIsolatedOverPort`), the host facade builder (`createIsolatedService`), the Web
  Worker transport (`spawnIsolated`/`createWorkerTransport`/`WorkerResolver`), `isolateFunction`, the
  tiered codec (`transfer`, `registerEncodableClasses`, `isEncoderAvailable`), `createCrashPolicy`, and
  observability types/exceptions.
* Node `child_process` transport (`forkIsolated`/`createChildProcessTransport`/`ChildResolver`) is a
  SEPARATE deep import, `@nhtio/adk/batteries/isolation/child_process` — NOT re-exported from the main
  barrel or the batteries aggregate, because it imports `node:child_process` directly and would be unsafe
  to pull into a browser-loadable entry point.
* `IsolatedServiceOptions` (shared by both transports' sugar functions and `createIsolatedService`
  directly): `readyTimeoutMs` (default `30_000`), `disposeGraceMs` (default `2000`), `autoRespawn: {
  policy: CrashPolicy }` (default: off — crashes surface, never auto-heal), `encodables:
  ReadonlyArray<{ name: string }>` (sugar over `@nhtio/encoder`'s `registerClass`, lazy), plus every
  `IsolationObservabilityHooks` field.
* `SpawnIsolatedOptions` (browser) adds `worker: string | URL | WorkerResolver` (required) and
  `workerOptions?: BrowserWorkerOptions` (`type` default `'classic'`).
* `ForkIsolatedOptions` (node) adds exactly one of `{ modulePath: string | URL, forkOptions?:
  child_process.ForkOptions }` or `{ spawn: ChildResolver }` — mutually exclusive, enforced by a local
  validator; supplying both or neither throws `E_INVALID_ISOLATION_OPTIONS`. `forkOptions.serialization`
  defaults to `'advanced'` unless overridden.
* `IsolatedService<S>` (what every entry point returns): `api` (built once, not a `Proxy`), `on(channel,
  fn)`, `onCrash(fn)`, `state: 'starting' | 'ready' | 'crashed' | 'disposed'`, `dispose()`, `recycle()`.
* Ten exceptions across the battery: `E_INVALID_ISOLATION_OPTIONS` (529, fatal), `E_ISOLATED_CRASHED`
  (503), `E_ISOLATED_TERMINATED` (499), `E_ISOLATION_READY_TIMEOUT` (504), `E_ISOLATION_ENCODER_REQUIRED`
  (528), `E_ISOLATION_UNENCODABLE` (500), `E_ISOLATION_UNSUPPORTED_ENV` (500, fatal, guest-side only) —
  plus `isolateFunction`'s own three: `E_ISOLATE_FUNCTION_REQUIRES_SOURCE_REHYDRATION` (529, fatal),
  `E_ISOLATE_FUNCTION_UNSERIALIZABLE` (529, fatal), `E_ISOLATE_FUNCTION_ARG_UNSUPPORTED` (528).
* `createCrashPolicy(options?)`: `windowMs` (default `120_000`), `maxCrashes` (default `3`), `now`
  (injectable clock). `record()` returns `'respawn'` while the in-window crash count stays under
  `maxCrashes`, `'giveUp'` once it reaches it; a window with no crash for `windowMs` resets the count.
* `IsolationObservabilityHooks`: firehose `onIsolation` plus nine per-phase-group hooks (`onSpawn`,
  `onDispose`, `onRecycle`, `onCrashReport`, `onRespawnAuto`, `onCall`, `onStream`, `onAbort`, `onWire`,
  `onCodecEscalate`) covering 18 `IsolationReportPhase` values; `debugPayloads` (default `false`) attaches
  raw payload bodies to `call:*`/`wire:*` reports.
* Decision tree: heavy/untrusted work reachable from a browser bundle → `spawnIsolated` (Web Worker); same
  from node, especially with a native-dependency-heavy or crash-prone guest → `forkIsolated`
  (`child_process`); a one-off, already-in-memory pure function you want off the main thread without a
  separate guest file → `isolateFunction` (browser-only, permanent-crash escape hatch); none of the above
  → don't reach for this battery at all, it is opt-in.

The [Isolation battery](/batteries/isolation/) hub page covers the thesis and a quick start; [Browser](/batteries/isolation/browser)
and [Node](/batteries/isolation/node) cover each transport's own API in narrative form. This page is the
side-by-side option/exception reference — reach for it when you already know which transport you want and
need the exact option shape or exception surface.

## Barrels, side by side

| | Browser | Node |
| --- | --- | --- |
| Entry point | `spawnIsolated` | `forkIsolated` |
| Barrel | `@nhtio/adk/batteries/isolation` | `@nhtio/adk/batteries/isolation/child_process` |
| Re-exported from batteries aggregate? | Yes | No — deep import only |
| Transport | Web Worker | `node:child_process` |
| Transport builder | `createWorkerTransport` | `createChildProcessTransport` |
| BYO spawn hook | `WorkerResolver` | `ChildResolver` |
| Options type | `SpawnIsolatedOptions` | `ForkIsolatedOptions` |

Everything spec-shaped (`defineIsolatedService`, `method`, `stream`, `event`), the guest server
(`serveIsolated`, `serveIsolatedOverPort`), `createIsolatedService` itself, the tiered codec
(`transfer`, `registerEncodableClasses`), `createCrashPolicy`, and the observability/exception types all
live on the main `@nhtio/adk/batteries/isolation` barrel — safe to import from either environment. The
`child_process` deep import adds only what actually touches node's own API: `forkIsolated`,
`createChildProcessTransport`, `ChildResolver`, and the `ForkIsolated*Options` types.

## `IsolatedServiceOptions` — shared by every entry point

| Option | Type | Default |
| --- | --- | --- |
| `readyTimeoutMs` | `number` | `30_000` |
| `disposeGraceMs` | `number` | `2000` |
| `autoRespawn` | `{ policy: CrashPolicy }` | not set (crashes surface, never auto-heal) |
| `encodables` | `ReadonlyArray<{ name: string }>` | `[]` (sugar over `@nhtio/encoder`'s `registerClass`, lazy) |
| `onIsolation` … `onCodecEscalate`, `debugPayloads` | `IsolationObservabilityHooks` | see [Observability](#observability) below |

`createIsolatedService(spec, transport, options?)` accepts exactly this shape directly, over any {@link
IsolationTransport}; `spawnIsolated`/`forkIsolated` extend it with their own transport-selection fields
below and strip those transport-only keys before forwarding the rest.

### Browser — `SpawnIsolatedOptions`

| Option | Type | Default |
| --- | --- | --- |
| `worker` | `string \| URL \| WorkerResolver` | — (required) |
| `workerOptions` | `BrowserWorkerOptions` (`{ type?, credentials?, name? }`) | `type` omitted (classic script) |

A `WorkerResolver` — `(ctx: { spec }) => BrowserWorker | Promise<BrowserWorker>` — is invoked once per
`connect()`, including every `recycle()`; it is the single source of Worker creation for the service's
whole lifetime. `workerOptions` only applies to the `string | URL` spawn form.

### Node — `ForkIsolatedOptions`

`IsolatedServiceOptions` plus exactly one of:

| Variant | Fields |
| --- | --- |
| `ForkIsolatedModuleOptions` | `modulePath: string \| URL` (required), `forkOptions?: child_process.ForkOptions` |
| `ForkIsolatedResolverOptions` | `spawn: ChildResolver` (required) |

Supplying both `modulePath` and `spawn` (or neither) throws `E_INVALID_ISOLATION_OPTIONS` before anything
is spawned. Unless `forkOptions.serialization` is set explicitly, `forkIsolated` pins `serialization:
'advanced'` — see [Node → why `serialization: 'advanced'` is pinned](/batteries/isolation/node#why-serialization-advanced-is-pinned)
for what silently degrades under the node default. A `ChildResolver` — `(ctx: { spec }) =>
IsolatedChildLike | Promise<IsolatedChildLike>` — is invoked once per `connect()`/`recycle()`, exactly
like a `WorkerResolver`; the returned value only needs to satisfy `IsolatedChildLike` (`send?`, `on`,
`off?`/`removeListener?`, `kill`, `connected?`).

## `IsolatedService<S>` — what every entry point returns

| Member | Shape | Notes |
| --- | --- | --- |
| `api` | `IsolatedFacade<S>` | Built once, up front, by iterating `spec.methods`/`spec.streams` — not a `Proxy`. |
| `on(channel, fn)` | `(channel, listener) => () => void` | Subscriptions survive `recycle()`. |
| `onCrash(fn)` | `(info: CrashInfo) => () => void` | |
| `state` | `'starting' \| 'ready' \| 'crashed' \| 'disposed'` | |
| `dispose()` | `() => Promise<void>` | Sends `shutdown`, waits `disposeGraceMs`, then forces `transport.terminate()` regardless. Idempotent. |
| `recycle()` | `() => Promise<void>` | Terminates and reconnects through the same `transport.connect()`; object identity and `.on(...)` subscriptions survive. |

Calls made before the guest's `ready` envelope arrives queue transparently and flush in order once it
does; `readyTimeoutMs` rejects the connection attempt with `E_ISOLATION_READY_TIMEOUT` if `ready` never
arrives (and rejects immediately, without waiting out the full timeout, if the guest crashes before
`ready`).

## Crash policy — `createCrashPolicy`

| Option | Type | Default |
| --- | --- | --- |
| `windowMs` | `number` | `120_000` |
| `maxCrashes` | `number` | `3` |
| `now` | `() => number` | `Date.now` (injectable for tests) |

`record()` prunes crashes older than `windowMs`, then returns `'respawn'` while the remaining in-window
count (including this crash) is still under `maxCrashes`, `'giveUp'` once it reaches it. Two crashes
spaced further apart than `windowMs` are each treated as a fresh, first-in-window crash — an occasional
isolated crash over a long-running session never escalates. This generalizes the flagship's
`GpuLossPolicy` (`docs/.vitepress/theme/components/agent/gpu_loss_policy.ts`) into a domain-neutral,
N-rung, pure decider any transport's crash can consult — see the showcase's
[Surviving the GPU](/showcase/punching-above-its-weights#surviving-the-gpu) section for the original,
hand-rolled 2-rung version this replaces.

## Observability

`IsolationObservabilityHooks` is mixed into every entry point's options (host-side: `createIsolatedService`,
`spawnIsolated`, `forkIsolated`; guest-side: `serveIsolated`, `serveIsolatedOverPort`). Every reported
phase fires the firehose `onIsolation` AND its matching per-phase-group hook:

| Per-phase-group hook | `IsolationReportPhase` values |
| --- | --- |
| `onSpawn` | `spawn:start`, `spawn:ready`, `spawn:error` |
| `onDispose` | `dispose:start`, `dispose:done` |
| `onRecycle` | `recycle:start`, `recycle:done` |
| `onCrashReport` | `crash` |
| `onRespawnAuto` | `respawn:auto` |
| `onCall` | `call:start`, `call:settle` |
| `onStream` | `stream:start`, `stream:end`, `stream:error`, `stream:cancel` |
| `onAbort` | `abort:sent` |
| `onWire` | `wire:out`, `wire:in` |
| `onCodecEscalate` | `codec:escalate` |

`debugPayloads: true` (default `false`) additionally attaches the raw payload body to `call:*`/`wire:*`
reports (`IsolationReport.payload`) — a separate opt-in on top of the hooks themselves, since payloads may
be large or carry sensitive data. `emitIsolationReport` skips assembling a report entirely when no
relevant hook is registered, so an unhooked service pays zero overhead for this surface. See
[Recipes → observability](/batteries/isolation/recipes#recipe-observability) for a wired example.

## Exceptions

| Exception | Status | Fatal? | Thrown when |
| --- | --- | --- | --- |
| `E_INVALID_ISOLATION_OPTIONS` | 529 | Yes | An options bag (spec input, host options, transport-specific spawn shape, crash-policy options) fails eager validation — unknown key, duplicate spec name, or (node) both/neither of `modulePath`/`spawn`. |
| `E_ISOLATION_UNSUPPORTED_ENV` | 500 | Yes | `serveIsolated()` cannot duck-detect a supported guest environment (guest-side only). |
| `E_ISOLATION_READY_TIMEOUT` | 504 | No | The guest's `ready` envelope never arrived within `readyTimeoutMs`. |
| `E_ISOLATED_CRASHED` | 503 | No | A call/stream is made (or in flight) after the guest crashed and no `autoRespawn`/manual `recycle()` has brought it back. |
| `E_ISOLATED_TERMINATED` | 499 | No | A call/stream was in flight during `dispose()`/`recycle()` and got rejected as part of teardown. |
| `E_ISOLATION_ENCODER_REQUIRED` | 528 | No | A value needs to escalate past the `raw` codec tier (function, `Error`, custom-encodable) but the optional `@nhtio/encoder` peer isn't installed. |
| `E_ISOLATION_UNENCODABLE` | 500 | No | A value can't be encoded even with the encoder peer available (e.g. a circular reference that also contains an exotic leaf). |
| `E_ISOLATE_FUNCTION_REQUIRES_SOURCE_REHYDRATION` | 529 | Yes | `isolateFunction` called without the literal `{ allowSourceRehydration: true }` acknowledgement. |
| `E_ISOLATE_FUNCTION_UNSERIALIZABLE` | 529 | Yes | The function handed to `isolateFunction` can't be serialized — native and bound functions have no inspectable source. Detected before any Worker is spawned. |
| `E_ISOLATE_FUNCTION_ARG_UNSUPPORTED` | 528 | No | An `invoke()` argument contains a function/`Error`/custom-encodable — the Blob guest has no module imports and cannot decode an escalated value; pass only plain, structured-cloneable arguments. |

The fatal members (status 529 or `E_ISOLATION_UNSUPPORTED_ENV`'s 500) are config/environment/call-site
bugs raised before anything is spawned — fail loud, not at first use. The rest are non-fatal, raised from
an async call/stream/connect path.

## Decision tree

* Heavy or untrusted work that must stay reachable from a **browser** bundle → [`spawnIsolated`](/batteries/isolation/browser)
  (Web Worker transport).
* Same, from **node** — especially a native-dependency-heavy or crash-prone guest where a process-level
  fault boundary matters → [`forkIsolated`](/batteries/isolation/node) (`child_process` transport).
* A one-off, already-in-memory **pure function** you want off the main thread without writing a separate
  guest file, and whose source you trust (your own process produced it) → [`isolateFunction`](/batteries/isolation/browser#isolatefunction-the-blob-url-escape-hatch)
  — browser-only, permanent-crash (no `autoRespawn`), not part of the `defineIsolatedService` pipeline.
* None of the above → this battery is opt-in; every other battery in this repo works standalone, on the
  main thread, in the same process.

## Where to go next

* [Isolation battery](/batteries/isolation/) — the thesis and quick start.
* [Browser](/batteries/isolation/browser) — `spawnIsolated`, `transfer()`, `isolateFunction`, narrative form.
* [Node](/batteries/isolation/node) — `forkIsolated`, the execa hazards, narrative form.
* [Recipes](/batteries/isolation/recipes) — the LiteRT-shape refit, isolated embeddings, custom classes
  across the wire, observability wiring.
* [Specialist batteries](/assembly/batteries-specialists) — the same assembly-guide shape for a different
  battery domain.
