Go-style concurrency and parallelism for JavaScript. Worker threads deliver true CPU parallelism across cores. Go-style chan, WaitGroup, ErrGroup, select, and context manage concurrency — zero dependencies, no worker files, no boilerplate.
import { spawn } from '@dmop/puru'
const { result } = spawn(() => {
let sum = 0
for (let i = 0; i < 100_000_000; i++) sum += i
return sum
})
console.log(await result) // runs off the main thread| Raw worker_threads | puru |
|---|---|
const { Worker } = require('worker_threads')
const worker = new Worker('./worker.js')
worker.postMessage({ n: 40 })
worker.on('message', (result) => {
console.log(result)
worker.terminate()
})
worker.on('error', reject)
// worker.js (separate file)
const { parentPort } = require('worker_threads')
parentPort.on('message', ({ n }) => {
parentPort.postMessage(fibonacci(n))
}) |
import { spawn } from '@dmop/puru'
const { result } = spawn(() => {
function fibonacci(n: number): number {
if (n <= 1) return n
return fibonacci(n - 1) + fibonacci(n - 2)
}
return fibonacci(40)
})
try {
console.log(await result)
} catch (err) {
console.error(err)
} |
One file. No message plumbing. Automatic pooling.
Zero runtime dependencies — just the library itself.
npm install @dmop/puru
# or
bun add @dmop/puruimport { spawn, WaitGroup, chan, task } from '@dmop/puru'
// CPU work on a dedicated worker
const { result } = spawn(() => {
function fibonacci(n: number): number {
if (n <= 1) return n
return fibonacci(n - 1) + fibonacci(n - 2)
}
return fibonacci(40)
})
// Reusable worker logic with explicit arguments
const crunch = task((n: number) => {
let sum = 0
for (let i = 0; i < n; i++) sum += i
return sum
})
// Parallel batch — wait for all
const wg = new WaitGroup()
wg.spawn(() => 21 * 2)
wg.spawn(() => 6 * 7)
const [a, b] = await wg.wait()
const bigNumber = await result
const heavySum = await crunch(1_000_000)
console.log({ a, b, bigNumber, heavySum })
// Cross-thread channels
const ch = chan<number>(10)
spawn(async ({ ch }) => {
for (let i = 0; i < 10; i++) await ch.send(i)
ch.close()
}, { channels: { ch } })
for await (const item of ch) console.log(item)Measured on Apple M1 Pro (8 cores). Full results in BENCHMARKS.md.
| Benchmark | Single-threaded | puru | Speedup |
|---|---|---|---|
| Fibonacci (fib(38) x8) | 4,345 ms | 2,131 ms | 2.0x |
| Prime counting (2M range) | 335 ms | 77 ms | 4.4x |
| 100 concurrent async tasks | 1,140 ms | 16 ms | 73x |
| Fan-out pipeline (4 workers) | 176 ms | 51 ms | 3.4x |
Spawn overhead: ~0.1-0.5ms. Use for tasks above ~5ms.
| Mode | Kind | Use it for | What happens |
|---|---|---|---|
spawn(fn) |
Parallelism | CPU-bound work | Dedicated worker thread — runs on a separate CPU core |
spawn(fn, { concurrent: true }) |
Concurrency | Async / I/O work | Shares a worker's event loop (M:N scheduling) |
| Situation | Tool |
|---|---|
| One heavy CPU task | spawn(fn) |
| Same logic, many inputs | task(fn) |
| Wait for all tasks | WaitGroup |
| Fail-fast, cancel the rest | ErrGroup (with setLimit() for throttling) |
| Timeouts and cancellation | context + spawn(fn, { ctx }) |
| Producer/consumer pipelines | chan() + select() |
Functions passed to
spawn()cannot capture outer variables. They are serialized as text and sent to a worker — closures don't survive.
const x = 42
spawn(() => x + 1) // ReferenceError at runtime
spawn(() => {
const x = 42 // define inside
return x + 1
}) // worksUse task(fn) to pass arguments to reusable worker functions.
Coordination: chan() · WaitGroup · ErrGroup · select() · context
Synchronization: Mutex · RWMutex · Once · Cond
Timing: after() · ticker() · Timer
Ergonomics: task() · configure() · stats() · directional channels · channel len/cap
All modeled after Go's concurrency primitives. Full API in docs/API.md.
Promise.all() — Great for cheap async work. Use puru when work is CPU-heavy or you need the main thread to stay responsive.
worker_threads — Powerful but low-level: separate files, manual messaging, manual pooling, no channels/WaitGroup/select. puru keeps the power, removes the ceremony.
Cluster — Cluster adds processes for request throughput. puru offloads heavy work inside each process. They compose well together.
| Runtime | Status |
|---|---|
| Node.js >= 20 | Full support |
| Bun | Full support |
| Deno | Planned |
import { configure } from '@dmop/puru'
configure({ adapter: 'inline' }) // runs on main thread, no real workers- Choosing the right primitive
- API reference
- How it works
- Benchmarks
- Production use cases
- Examples
- AI assistant guide
spawn()functions cannot capture outer variables (see The Big Rule)- Channel values must be structured-cloneable (no functions, symbols, WeakRefs)
nullis reserved as the channel-closed sentineltask()arguments must be JSON-serializable
MIT