JavaScript has had workers for fifteen years, and the way most apps use
them hasn't changed: pick the expensive function, post it some data,
await the result. That works, until the work isn't a function call.
It's a million-row scan. It's a UI tree re-rendering every frame. It's
state that both threads need live, not cloned-and-stale.
AtollJS is built around four goals, each one a layer:
1. Share state, don't serialize it
postMessage clones everything, fine for results, wrong for state a
worker updates continuously. The raw alternative, SharedArrayBuffer,
is fast and gives you nothing to keep both sides honest.
So the layout is a type. defineSharedMemory takes a spec of
fixed-width fields and compiles it to a deterministic byte layout,
one declaration, imported by main and worker alike:
// incidents.memory.ts - the contract both threads import
import { defineSharedMemory, field, reef } from '@atolljs/core';
export const incidentsMemory = defineSharedMemory({
lists: {
incidents: field.list({
schema: reef.object({
id: reef.u32(),
severity: reef.int(0, 3), // domain bound → narrowest width (u8)
site: reef.string(10), // 10 inline UTF-8 bytes, schema-enforced
open: reef.boolean(), // flag byte
}),
count: 1_000_000,
}),
},
signals: { seedProgress: field.number() },
});
The schema is the layout: reef mints validators the compiler reads a
byte width from, so the two sides can't disagree - there's only one
source of offsets. And it costs nothing you didn't ask for: the schema
engine is vendored into reef, so no zod dependency ships with the SDK.
Deep dive: Shared Memory Is a Contract, Not a Buffer.
2. Make worker calls feel like method calls
A worker is an RPC surface - it should look like one. defineWorker
declares the method list inside the worker; connectWorker gives the
main thread a typed proxy over a lazily-spawned pool:
// incidents.worker.ts - worker side owns the runtime
import { defineWorker } from '@atolljs/core';
export const incidentsWorker = defineWorker({
sharedMemory: incidentsMemory,
methods: {
async seedIncidents(n: number) {
/* writes straight into shared memory - results don't cross postMessage */
},
queryIncidents(q: QueryArgs) { /* scan in place, return the page */ },
},
});
// main.ts - the client is a Proxy typed by typeof worker
import { connectWorker } from '@atolljs/core';
import type { IncidentsWorker } from './incidents.worker'; // type-only
const incidents = connectWorker<IncidentsWorker>({
sharedMemory: incidentsMemory,
worker: () => new Worker(
new URL('./incidents.worker.ts', import.meta.url), { type: 'module' },
),
poolSize: 'auto',
});
await incidents.queryIncidents({ offset: 0, limit: 50 });
import type matters: worker code never enters the app bundle. Under
the hood the pool handles what bare workers don't - queueing,
taskTimeout, cancellation, crash respawn. A pool call always settles.
Deep dive: Worker Pools That Fail Gracefully.
3. Let results flow back as reactivity
The payoff of shared memory is live reads - main doesn't await a
return value, it watches the field the worker is writing:
import { observe, defineTask } from '@atolljs/core';
const progress = observe(incidentsMemory, 'signals.seedProgress');
// snapshot-stable, refcounted - activates on first subscriber
const queryTask = defineTask((q: QueryArgs) => incidents.queryIncidents(q));
// { data, pending, settled, elapsedMs, error } - latest-wins by default
observe and defineTask are the primitives; framework bindings adapt
them - useSharedValue/useTask in React, equivalents in Vue, Solid,
Svelte, Angular, Next.js. Your component subscribes; the worker writes;
the diff flows through the version counter, not a message.
Deep dive: Reactivity Without Messages.
4. Render UI itself off-thread
Push the idea to its limit and the framework tree can live in the
worker too. @atolljs/islands mounts a registered app inside a worker;
it renders against a proxy document and streams serialized DOM ops back
to a driver that replays them. A React island, end to end:
// counter.app.tsx - a plain React component; it runs INSIDE the worker.
// islandApp() stamps it with its registry key (minification-proof).
import { useState } from 'react';
import { islandApp, type EventPayload } from '@atolljs/islands/worker';
import { emit } from '@atolljs/react-island/worker';
// Worker-side handlers get the wire payload - { type, value, key } -
// not a SyntheticEvent; handler() adapts it to JSX's event prop types.
const handler = <E,>(fn: (e: EventPayload) => void): ((e: E) => void) =>
fn as unknown as (e: E) => void;
export const CounterApp = islandApp('counter', function CounterApp({
label = 'count',
}: {
label?: string;
}) {
const [count, setCount] = useState(0); // state never leaves the worker
return (
<button
onClick={handler(() => {
const n = count + 1;
setCount(n);
emit('incremented', { count: n }); // island → shell channel
})}
>
{label}: {count}
</button>
);
});
// counter.worker.tsx - the whole worker entry
import { defineReactPolyWorker } from '@atolljs/react-island/worker';
export const counterWorker = defineReactPolyWorker({
apps: { counter: CounterApp }, // one worker, many islands
});
// counter.island.ts - a shell-safe contract module: registry key + worker
// factory, no worker code imported. The dynamic import is the bundler's
// split point - it (and the worker entry) only loads when the island mounts.
export const app = 'counter';
export const worker = () =>
new Worker(new URL('./counter.worker.tsx', import.meta.url), { type: 'module' });
// app.tsx - the shell is ordinary React; the facade is the boundary.
// lazyIsland mints a proxy component off the contract - attrs ARE the props.
import { Suspense } from 'react';
import { lazyIsland } from '@atolljs/react-island';
const CounterIsland = lazyIsland(() => import('./counter.island'));
export function App() {
return (
<Suspense fallback={<p>mounting worker…</p>}>
<CounterIsland
label="clicks" // serialized across postMessage
onEvent={(name) => name === 'incremented' && console.log('worker-side click')}
/>
</Suspense>
);
}
// main.tsx - an ordinary bootstrap; React on both sides, DOM on one
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')!).render(<App />);
Render, diff, and state all run off the main thread - what's left behind
is a thin shell replaying ops. The facade wraps the same mountIsland
driver call - the contract's dynamic import is the split point, so a
heavy island's chunk only loads when it mounts - and the driver takes a
bare element on framework-free shells.
Per-framework packages (react-island, vue-island, …) keep each
worker's renderer to the framework it actually uses.
Deep dive: Islands That Render in Workers.
On the server - NestJS
Everything above is the browser story; the pool doesn't change when the
runtime does. On node:worker_threads, @atolljs/nestjs puts the pool
behind dependency injection: AtollModule.registerPool makes it an
injectable provider, and @AtollService makes a service class the
interop surface - inject it anywhere and every method dispatches:
// digest.service.ts - the decorator picks the thread
import { Injectable } from '@nestjs/common';
import { AtollService } from '@atolljs/nestjs/decorators';
import { defineSharedMemory, field } from '@atolljs/core';
export const digestMemory = defineSharedMemory({ jobsDone: field.number() });
@Injectable()
@AtollService({ pool: 'digest' }) // every method dispatches to the pool
export class DigestService {
async hash(input: string) {
// executes inside the worker's own Nest context
// injected dependencies resolve there too
}
}
// digest.module.ts - the API-thread side: the feature module owns the
// pool and the shared memory it was built on
import { Module } from '@nestjs/common';
import { Worker } from 'node:worker_threads';
import { AtollModule } from '@atolljs/nestjs';
import { digestMemory, DigestService } from './digest.service';
@Module({
imports: [
AtollModule.registerPool({
name: 'digest',
// webpack emits the worker entry as its own chunk
worker: () => new Worker(new URL('./digest.worker.ts', import.meta.url)),
sharedMemory: digestMemory,
poolSize: 2,
}),
],
providers: [DigestService],
})
export class DigestAtollModule {}
// digest.worker.ts - the whole worker entry
import { runAtollWorker } from '@atolljs/nestjs/worker';
void runAtollWorker(DigestAtollModule);
// digest.controller.ts - injecting the service is the whole consumer story
import { Body, Controller, Post } from '@nestjs/common';
@Controller('api/digest')
export class DigestController {
constructor(private readonly digest: DigestService) {}
@Post('hash')
hash(@Body() body: { input: string }) {
return this.digest.hash(body.input); // dispatches to the pool
}
}
// app.module.ts - forRoot once; feature modules own their pools
@Module({
imports: [AtollModule.forRoot(), DigestAtollModule],
controllers: [DigestController],
})
export class AppModule {}
// main.ts - an ordinary bootstrap; nothing about it is worker-aware
import { NestFactory } from '@nestjs/core';
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // pools terminate on module destroy
await app.listen(3100);
Each worker boots its own Nest application context, so the method body
runs with real DI on both sides - the class file is the contract, the
same discipline as every browser layer. Node needs no COOP/COEP for
SharedArrayBuffer, so shared memory comes along free; and a route
subtree can even be housed inside workers outright, proxied at the
framework boundary.
Deep dive: Dependency Injection Across the Boundary.
What ties it together
Every layer leans on the same discipline: declare once, in types both
threads share. The memory contract is a schema; the worker surface is
typeof your worker module; islands scope every mount to an app@N
instance. Nothing is duplicated across the boundary - so nothing drifts.
And the footprint follows the same rule. Dependencies stay external and
opt-in - no shared-memory fields means no schema code in your bundle at
all; islands need no SharedArrayBuffer and no special headers, so the
graceful-degradation story is real. The whole stack is what you use of
it, byte for byte.
Top comments (0)