Next.js — server workers

@atolljs/node inside a Next.js app: route handlers run on Node, so a handler can own a real node:worker_threads pool — dispatch CPU-bound tasks without blocking the request thread, and read the pool's shared memory directly on the API thread for zero-dispatch responses. The client-side hooks are documented under Frontend → Next.js.

Install

backend
npm install @atolljs/core @atolljs/node

Route handler — the pool owner

src/app/api/atoll/route.tsbackend
// src/app/api/atoll/route.ts — route handlers run on Node,
// so @atolljs/node works inside them: a pool of node:worker_threads
// workers sharing one buffer with the API thread.
import { NextResponse } from 'next/server';
import { Worker } from 'node:worker_threads';
import { createNodePool, createNodeWorker } from '@atolljs/node';
import { digestMemory, HashDigest } from './digest.contract';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

// Module-scope singleton via globalThis — dev-mode HMR re-evaluates the
// module; without it every hot reload would leak worker threads.
const createPool = () =>
  createNodePool({
    sharedMemory: digestMemory,
    poolSize: 2,
    tasks: { hash: HashDigest },
    // webpack/turbopack detect new Worker(new URL(...)) and emit the
    // worker entry as its own chunk — the factory points at TS source.
    createWorker: () => createNodeWorker(
      new Worker(new URL('./atoll.worker.ts', import.meta.url)),
    ),
  });
type DigestPool = ReturnType<typeof createPool>;

const getPool = (): DigestPool => {
  const g = globalThis as { __atollDigestPool?: DigestPool };
  return (g.__atollDigestPool ??= createPool());
};

export async function POST(req: Request) {           // CPU-bound work, off the
  const body = await req.json().catch(() => ({}));   // request thread
  return NextResponse.json(await getPool().hash(body?.input, body?.rounds));
}

export async function GET() {   // direct shared-memory read — zero dispatch
  return NextResponse.json({ jobsDone: digestMemory.jobsDone.read() });
}
src/app/api/atoll/atoll.worker.tsbackend
// src/app/api/atoll/atoll.worker.ts — the shim MUST be
// first: it binds self = parentPort before workerBootstrap wires
// INIT_MEMORY / EXECUTE_TASK onto the node:worker_threads MessagePort.
import '@atolljs/node/shim';
import '@atolljs/core/worker/workerBootstrap';
import { TaskRegistry } from '@atolljs/core';
import { createHash } from 'node:crypto';
import { digestMemory, HashDigest } from './digest.contract';

TaskRegistry.register(HashDigest, (input = 'incident-feed', rounds = 50_000) => {
  // chained SHA-256 … then digestMemory.jobsDone.write(…) — the route
  // handler's GET reads it back without touching the pool.
});

The repo's examples/nextjs implements this pattern — it exposes POST /api/atoll ({"input","rounds"} → chained SHA-256 on a worker) and GET /api/atoll (a direct shared-memory read of jobsDone); see examples/nextjs/src/app/api/atoll/. Run it locally with npm run dev in the example (or npm run serve:all from the repo root) — there is no embedded demo here because a static docs host has no Node runtime.

Going further

The example builds three more use cases on this pattern, each with a dedicated page: a job queue whose progress counters live in shared memory, a read-model API serving a 1M-record buffer with zero dispatch, boot warmup via Next's instrumentation hook, and the custom-server topology for clustering and WebSockets.

Notes

  • Requires the nodejs runtime (the default for route handlers) — output: 'export' drops handlers entirely, so the pool needs a Node host or next start.
  • Hold the pool in a module-scope singleton on globalThis — dev-mode HMR re-evaluates the module, and without it every hot reload leaks worker threads.
  • No COOP/COEP needed — Node always allows SharedArrayBuffer.