---
url: 'https://adk.nht.io/batteries/isolation/browser.md'
description: >-
  spawnIsolated, the WorkerResolver bundler seam, transfer() zero-copy, crash
  recovery, and the isolateFunction Blob-URL escape hatch.
---

# Browser: Web Workers

## LLM summary — Isolation battery (browser)

* [`spawnIsolated`](https://adk.nht.io/api/@nhtio/adk/batteries/functions/spawnIsolated)`(spec, options)` = `createIsolatedService(spec, createWorkerTransport(spec,
  options), options)` — the `worker`/`workerOptions` keys are transport-only and stripped before the rest
  reach `createIsolatedService`'s strict (unknown-key-rejecting) validator.
* `options.worker` is `string | URL | WorkerResolver`. A `string | URL` constructs `new Worker(url,
  workerOptions)` on every `connect()`/`recycle()` internally. A [`WorkerResolver`](https://adk.nht.io/api/@nhtio/adk/batteries/type-aliases/WorkerResolver) — `(ctx: { spec }) => BrowserWorker | Promise<BrowserWorker>` — is called once per `connect()` INCLUDING every `recycle()`,
  making it the single source of Worker creation for the transport's whole lifetime (a custom bundler
  pattern, a worker pool, a test harness's Blob worker).
* Classic scripts are the default (`workerOptions.type` omitted) — matches this repo's own LiteRT-LM worker
  prototype, whose Emscripten glue calls `importScripts()` (illegal inside `{ type: 'module' }`). Pass `{
  type: 'module' }` explicitly for an ES-module guest script.
* `transfer(value, transferables)` (from `codec.ts`) marks a value for zero-copy `postMessage` transfer.
  `browser.ts`'s `collectTransferables` scans the outbound envelope's known `WireValue` positions
  (`call.args[]`, `stream:start.args[]`, `result.value`, `stream:delta.delta`) and forwards the collected
  list as `postMessage`'s second argument. Node's transport ignores the marker entirely — code written
  against `transfer()` works unmodified against either transport.
* Crash detection: the Worker's `'error'` event → `onCrash({ reason })`; the Worker's `'messageerror'`
  event (undeserializable message) → `onCrash({ reason: '...sent an undeserializable message' })`. A crash
  rejects in-flight calls/streams with `E_ISOLATED_CRASHED`, flips `state` to `'crashed'`, and fans out to
  `.onCrash(...)` subscribers; recover with `.recycle()` (manual) or `autoRespawn: { policy }` (automatic,
  via [`createCrashPolicy`](https://adk.nht.io/api/@nhtio/adk/batteries/functions/createCrashPolicy)).
* `isolateFunction(fn, { allowSourceRehydration: true })` is a SEPARATE escape hatch, not part of the
  `defineIsolatedService`/`spawnIsolated` pipeline: it serializes a plain function's source
  (`fn.toString()` via `@nhtio/encoder/function_serializer`), embeds it in a synthesized Blob-URL classic
  Worker, and rehydrates it with `new Function` guest-side. This is an eval-equivalent trust surface — only
  pass functions whose source your own process produced. Arguments are restricted to the raw codec tier
  (no functions/Errors/custom-encodables — the Blob guest has no module imports, so it cannot load
  `@nhtio/encoder` to decode an escalated value). A guest crash is PERMANENT (no auto-respawn) — construct a
  new handle to retry.
* Bundler pattern: `spawnIsolated(spec, { worker: new URL('./worker.ts', import.meta.url) })` (Vite/webpack
  `new URL(..., import.meta.url)` convention) — or a `WorkerResolver` for anything more custom.

## `spawnIsolated`

`spawnIsolated(spec, options)` is sugar for building a Web Worker [`IsolationTransport`](https://adk.nht.io/api/@nhtio/adk/batteries/interfaces/IsolationTransport) and handing it
straight to `createIsolatedService`:

```ts
import { spawnIsolated } from '@nhtio/adk/batteries/isolation'
import { CounterService } from './spec'

const counter = spawnIsolated(CounterService, {
  worker: new URL('./worker.ts', import.meta.url),
})

const total = await counter.api.increment(5)
```

`new URL('./worker.ts', import.meta.url)` is the standard Vite/webpack pattern for pointing a bundler at a
worker entry file it should build as a separate chunk. `worker.ts` itself is an ordinary guest — see the
[hub page's quick start](/batteries/isolation/#quick-start) for the `serveIsolated` guest shape; it looks
identical whether the transport ends up being a Worker or a node child process.

### `worker`: string, `URL`, or resolver

`SpawnIsolatedOptions.worker` accepts three shapes:

| Shape | Behavior |
| --- | --- |
| `string \| URL` | `createWorkerTransport` constructs `new Worker(url, workerOptions)` itself, on every `connect()` (including every `recycle()`). |
| [`WorkerResolver`](https://adk.nht.io/api/@nhtio/adk/batteries/type-aliases/WorkerResolver) | `(ctx: { spec }) => BrowserWorker \| Promise<BrowserWorker>` — full control over how the `Worker` is constructed. Called once per `connect()`/`recycle()`. |

Reach for a resolver when a plain URL isn't enough — a worker pool that recycles threads instead of
spawning fresh ones, a test harness that builds a Blob-URL worker, or any bundler integration that doesn't
fit the `new URL(..., import.meta.url)` shape:

```ts
import { spawnIsolated, type WorkerResolver } from '@nhtio/adk/batteries/isolation'
import { CounterService } from './spec'

const resolve: WorkerResolver = ({ spec }) => {
  console.log(`spawning a worker for "${spec.name}"`)
  return new Worker(new URL('./worker.ts', import.meta.url), { name: spec.name })
}

const counter = spawnIsolated(CounterService, { worker: resolve })
```

Because a resolver is invoked fresh on every `connect()`, it is the single source of Worker creation for the
service's entire lifetime — every automatic or manual `recycle()` goes through it too, never a cached
instance.

### `workerOptions` and module vs. classic workers

`workerOptions` is forwarded verbatim to `new Worker(url, workerOptions)` — it only applies to the `string |
URL` spawn form (a `WorkerResolver` constructs its own `Worker` and is responsible for its own options).
The default is a classic script (`type` omitted): that matches this repo's own LiteRT-LM worker prototype,
whose Emscripten-generated glue code calls `importScripts()` — a call that throws inside a `{ type: 'module'
}` worker. If your guest script is an ES module, opt in explicitly:

```ts
spawnIsolated(CounterService, {
  worker: new URL('./worker.ts', import.meta.url),
  workerOptions: { type: 'module' },
})
```

## `transfer()`: zero-copy for large payloads

Ordinary arguments and results cross via `codec.ts`'s tiered encoder at whatever tier they need — plain
JSON-safe values and the opaque containers (`ArrayBuffer`/`TypedArray`/`DataView`/`Date`/`RegExp`/`Map`/`Set`)
travel through `postMessage`'s structured clone untouched. For large binary payloads (audio frames, a big
`ArrayBuffer`), a structured-clone COPY is still a copy. `transfer()` marks a value so the browser transport
moves it by ownership transfer instead:

```ts
import { transfer } from '@nhtio/adk/batteries/isolation'

await counter.api.processAudio(transfer(audioBuffer, [audioBuffer]))
```

Under the hood, `browser.ts`'s `collectTransferables` scans every outbound envelope's known `WireValue`
positions (`call.args[]`, `stream:start.args[]`, `result.value`, `stream:delta.delta`) for a `transfer` list
and forwards everything it finds as `postMessage`'s second argument. This only matters on the browser
transport — node's `child_process` transport has no such concept and silently ignores the marker, so code
written against `transfer()` behaves correctly (just without the zero-copy benefit) if it's later run over
`forkIsolated` instead.

## Crash detection and recovery

A Worker's `'error'` event (an uncaught exception escaping the guest's top-level scope) and `'messageerror'`
event (a received message that failed to deserialize) both surface as an isolation crash: in-flight
calls/streams reject with `E_ISOLATED_CRASHED`, `state` flips to `'crashed'`, and every `.onCrash(...)`
subscriber fires with a `{ reason }` describing what happened.

```ts
counter.onCrash((info) => {
  console.error(`counter service crashed: ${info.reason}`)
})
```

Recovery is either manual (`await counter.recycle()` — terminates the dead worker and reconnects through
the exact same `WorkerResolver`/URL, and every `.on(...)` subscription plus the service object's identity
survive) or automatic via `autoRespawn: { policy }`, where `policy` is a [`createCrashPolicy`](https://adk.nht.io/api/@nhtio/adk/batteries/functions/createCrashPolicy) sliding
window: exceed `maxCrashes` within `windowMs` and the policy returns `'giveUp'` instead of triggering another
respawn. This is exactly the crash-escalation ladder the flagship's `GpuLossPolicy` implements by hand for
its WebGPU worker — see [Recipes](./recipes) for the isolation-battery equivalent, and the showcase's
[Surviving the GPU](/showcase/punching-above-its-weights#surviving-the-gpu) section for the original,
hand-rolled story this generalizes.

A disposable Worker is exactly the right response to a wedged GPU context: rather than trying to same-thread
`dispose()` a WebGPU device that may already be in a lost state, `recycle()` throws the whole Worker away
(and, with it, whatever GPU resources it held) and spawns a fresh one.

## `isolateFunction`: the Blob-URL escape hatch

Every other seam in this battery runs a guest script the caller wrote, deployed, and controls the
provenance of. `isolateFunction` is different: it takes an in-memory function VALUE and runs it inside a
throwaway Worker without you ever writing a separate guest file.

::: danger `new Function`-based eval trust surface
`isolateFunction` serializes a function via `fn.toString()` (through `@nhtio/encoder/function_serializer`),
embeds that source text verbatim into a synthesized Worker script, and has the Worker reconstruct it with
`new Function(...)` at startup. There is no way to do source rehydration without `new Function` or `eval`,
and this module does not pretend otherwise — that is why the call requires the literal `{
allowSourceRehydration: true }`, enforced both at the type level (a widened `boolean` fails to compile) and
at runtime (so an untyped call site, or an `as any` cast, cannot skip the acknowledgement). Treat any
function you hand to `isolateFunction` exactly as you would treat a string handed to `eval`: only ever pass
functions whose source your own process produced or controls. `fn.toString()` captures no closures — only
named, module-scope-free source is portable across the Blob boundary.
:::

```ts
import { isolateFunction } from '@nhtio/adk/batteries/isolation'

const heavy = isolateFunction(
  (n: number) => {
    let total = 0
    for (let i = 0; i < n; i++) total += Math.sqrt(i)
    return total
  },
  { allowSourceRehydration: true }
)

const result = await heavy.invoke(10_000_000)
heavy.dispose()
```

Arguments are restricted to the raw codec tier: the Blob guest has no module imports at all (a Blob URL
cannot resolve a bare specifier), so it cannot load `@nhtio/encoder` to decode an escalated (`enc: 'nhtio'`)
value. Passing a function, an `Error`, or a custom-encodable instance as an argument throws
`E_ISOLATE_FUNCTION_ARG_UNSUPPORTED` up front, before anything is even sent — plain, structured-cloneable
arguments only.

A guest crash (an uncaught error escaping the rehydrated function, or the rehydrator itself throwing) is
PERMANENT: `dispose()` is not called automatically and no `autoRespawn` option exists here — this is a
one-shot escape hatch, not a managed service. Construct a fresh `isolateFunction(...)` handle to try again.

## Where to go next

* [Node](./node) — the equivalent story for out-of-process isolation via `child_process`.
* [Recipes](./recipes) — a full crash-policy-driven refit of the flagship's LiteRT-LM worker pair onto this
  substrate.
* [Assembly → Isolation batteries](/assembly/batteries-isolation) — the full option surface side-by-side
  with node.
