DEV Community

Cover image for Breaking the Single-Threaded Barrier: How AtollJS Brings True Multithreading and Shared Memory to Modern JavaScript
Justin Henry
Justin Henry

Posted on AI-assisted

Breaking the Single-Threaded Barrier: How AtollJS Brings True Multithreading and Shared Memory to Modern JavaScript

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() },
});
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 />);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)