Skip to main content

Guide

piscina vs tinypool vs workerpool 2026

Compare piscina, tinypool, and workerpool for managing worker thread pools in Node.js. CPU-intensive tasks, task queuing, transferable objects, thread pool.

·PkgPulse Team·
0

TL;DR

piscina is the high-performance worker pool built by the Node.js team — supports transferable objects, task cancellation via AbortController, memory-efficient communication, and automatic worker recycling. tinypool is the minimal fork of piscina — used internally by Vitest, smaller API surface, ESM-native, same core functionality. workerpool is the mature cross-environment pool — works in Node.js AND browser (via Web Workers), offers named function execution, and statistics. In 2026: piscina for production Node.js services, tinypool if you want the lightest option, workerpool if you need browser compatibility.

Key Takeaways

  • piscina: ~3M weekly downloads — Node.js team, transferable objects, histogram metrics
  • tinypool: ~8M weekly downloads — fork of piscina, powers Vitest, ESM-first, minimal
  • workerpool: ~3M weekly downloads — cross-environment (Node + browser), mature, named functions
  • All three manage a pool of worker threads — submit tasks, get results, avoid main thread blocking
  • Worker threads share memory via SharedArrayBuffer and transfer ownership via Transferable
  • tinypool's download count is high because Vitest depends on it

Why Worker Thread Pools?

Problem: Node.js main thread handles I/O AND computation

CPU-intensive tasks block the event loop:
  Client A: GET /api/report    → generating PDF (5 seconds) → event loop blocked
  Client B: GET /api/health    → waiting... waiting... timeout!

Solution: Worker thread pool

  Main thread (event loop):
    Client A: POST /api/report → submit to pool → continue handling other requests
    Client B: GET /api/health  → responds immediately ✅

  Worker pool (background threads):
    Worker 1: generating PDF...
    Worker 2: resizing image...
    Worker 3: parsing CSV...
    Worker 4: idle (ready for next task)

Good candidates for worker threads:
  - PDF generation
  - Image processing (sharp, canvas)
  - CSV/Excel parsing (large files)
  - Crypto operations (bcrypt, scrypt)
  - Data transformation (large JSON → aggregate)
  - Code compilation/transpilation

piscina

piscina — high-performance worker pool:

Basic setup

// worker.ts — the code that runs in each worker thread:
export default function processPackage(data: { name: string; downloads: number[] }) {
  // CPU-intensive calculation in background thread:
  const avg = data.downloads.reduce((a, b) => a + b, 0) / data.downloads.length
  const stdDev = Math.sqrt(
    data.downloads.reduce((sum, d) => sum + (d - avg) ** 2, 0) / data.downloads.length
  )
  return { name: data.name, average: avg, stdDev, trend: avg > 100000 ? "growing" : "stable" }
}
// main.ts — submit tasks to the pool:
import Piscina from "piscina"
import { resolve } from "node:path"

const pool = new Piscina({
  filename: resolve(__dirname, "worker.js"),
  maxThreads: 4,       // Max 4 workers
  minThreads: 2,       // Keep 2 alive
  idleTimeout: 30_000, // Kill idle workers after 30s
})

// Submit tasks:
const result = await pool.run({
  name: "react",
  downloads: [5_000_000, 5_100_000, 4_900_000, 5_200_000],
})
console.log(result) // { name: "react", average: 5050000, stdDev: ..., trend: "growing" }

Express integration

import express from "express"
import Piscina from "piscina"

const pool = new Piscina({
  filename: resolve(__dirname, "workers/report-generator.js"),
  maxThreads: 4,
})

const app = express()

app.post("/api/reports/generate", async (req, res) => {
  try {
    // Offload CPU work to pool — event loop stays free:
    const report = await pool.run({
      packages: req.body.packages,
      dateRange: req.body.dateRange,
      format: "pdf",
    })

    res.setHeader("Content-Type", "application/pdf")
    res.send(report)
  } catch (error) {
    res.status(500).json({ error: "Report generation failed" })
  }
})

Task cancellation with AbortController

const controller = new AbortController()

// Cancel after 10 seconds:
const timeout = setTimeout(() => controller.abort(), 10_000)

try {
  const result = await pool.run(data, { signal: controller.signal })
  clearTimeout(timeout)
  return result
} catch (error) {
  if (error.name === "AbortError") {
    console.log("Task cancelled — took too long")
  }
  throw error
}

Transferable objects (zero-copy)

// worker.ts:
export default function processImage(buffer: ArrayBuffer) {
  // Process the buffer (no copy was made — ownership transferred)
  const view = new Uint8Array(buffer)
  // ... process image data ...
  return { width: 800, height: 600, size: view.length }
}

// main.ts:
const imageBuffer = await fs.readFile("image.png")
const arrayBuffer = imageBuffer.buffer.slice(
  imageBuffer.byteOffset,
  imageBuffer.byteOffset + imageBuffer.byteLength
)

// Transfer ownership (zero-copy — buffer moved, not copied):
const result = await pool.run(arrayBuffer, {
  transferList: [arrayBuffer],
})
// arrayBuffer is now detached (empty) — ownership transferred to worker

Pool statistics

// piscina exposes histogram metrics:
console.log({
  completed: pool.completed,         // Total completed tasks
  queueSize: pool.queueSize,         // Tasks waiting in queue
  utilization: pool.utilization,      // 0-1 utilization ratio
  runTime: pool.runTime,             // Histogram of task run times
  waitTime: pool.waitTime,           // Histogram of queue wait times
})

tinypool

tinypool — minimal piscina fork:

Basic setup

import Tinypool from "tinypool"

const pool = new Tinypool({
  filename: new URL("./worker.js", import.meta.url).href,  // ESM-native
  minThreads: 2,
  maxThreads: 4,
  idleTimeout: 30_000,
})

const result = await pool.run({ name: "react", downloads: [5_000_000] })

Worker (ESM)

// worker.ts (ESM):
export default function ({ name, downloads }: { name: string; downloads: number[] }) {
  const avg = downloads.reduce((a, b) => a + b, 0) / downloads.length
  return { name, average: avg }
}

Why Vitest uses tinypool

// Vitest runs each test file in a separate worker thread via tinypool:
// - Test files run in parallel across workers
// - Each worker has its own module cache (isolation)
// - Main thread coordinates results

// This is why:
// vitest --pool=threads   → tinypool (default)
// vitest --pool=forks     → child_process.fork
// vitest --pool=vmThreads → worker_threads + VM context

Differences from piscina

tinypool vs piscina:
  ✅ ESM-native (import.meta.url for worker path)
  ✅ Smaller bundle (~2 KB vs ~8 KB)
  ✅ Same core API (run, options, abort)
  ❌ No histogram metrics (pool.runTime, pool.waitTime)
  ❌ No transferList support (manual — pass via run options)
  ❌ Less configuration (simpler = fewer knobs)

workerpool

workerpool — cross-environment pool:

Named functions

// worker.ts — export named functions:
import workerpool from "workerpool"

function calculateScore(name: string, downloads: number[]): number {
  const avg = downloads.reduce((a, b) => a + b, 0) / downloads.length
  return Math.round(avg / 1000)
}

function generateReport(packages: string[]): string {
  return packages.map(p => `Report for ${p}`).join("\n")
}

// Register functions by name:
workerpool.worker({
  calculateScore,
  generateReport,
})
// main.ts — call by name:
import workerpool from "workerpool"

const pool = workerpool.pool("./worker.js", {
  maxWorkers: 4,
  minWorkers: 2,
})

// Call specific named functions:
const score = await pool.exec("calculateScore", ["react", [5_000_000, 5_100_000]])
const report = await pool.exec("generateReport", [["react", "vue", "svelte"]])

// Pool statistics:
const stats = pool.stats()
// → { totalWorkers: 4, busyWorkers: 2, idleWorkers: 2, pendingTasks: 0, activeTasks: 2 }

// Terminate pool:
await pool.terminate()

Browser support (Web Workers)

// workerpool works in the browser using Web Workers:
import workerpool from "workerpool"

// Browser — uses Web Workers automatically:
const pool = workerpool.pool()

// Inline function execution (no separate worker file):
const result = await pool.exec((a: number, b: number) => a + b, [2, 3])
// → 5

// Or with a worker URL:
const pool2 = workerpool.pool("/workers/processor.js")

Timeout and cancellation

const pool = workerpool.pool("./worker.js")

// Timeout:
try {
  const result = await pool.exec("generateReport", [largeDataset], {
    on: function (payload) {
      console.log("Progress:", payload)  // Worker can send progress updates
    },
  })
} catch (error) {
  // Handle worker errors
}

// Cancel via promise:
const promise = pool.exec("longRunningTask", [data])
promise.cancel()  // Terminates the worker running this task

Feature Comparison

Featurepiscinatinypoolworkerpool
Worker threads
Web Workers (browser)
ESM native✅ (better)⚠️
Named functions
AbortController❌ (cancel())
Transferable objects⚠️
Histogram metrics
Pool statistics
Inline execution
Worker recycling
TypeScript
Weekly downloads~3M~8M~3M

Thread Pool Sizing

import { availableParallelism } from "node:os"

const cpus = availableParallelism()  // e.g., 8

// CPU-bound tasks (image processing, crypto):
const pool = new Piscina({
  maxThreads: cpus,      // One thread per CPU core
  minThreads: cpus / 2,  // Keep half alive
})

// Mixed I/O + CPU tasks:
const pool = new Piscina({
  maxThreads: cpus * 2,  // More threads than cores (I/O waits)
  minThreads: cpus / 2,
})

// Memory-constrained (each worker uses ~50 MB):
const maxByMemory = Math.floor(totalMemoryMB / 50)
const pool = new Piscina({
  maxThreads: Math.min(cpus, maxByMemory),
})

Understanding Worker Thread Communication Overhead

Communication between the main thread and worker threads is not free. Passing data via postMessage serializes the argument using the structured clone algorithm, which is significantly slower than passing a simple object reference in the same thread. For large datasets (hundreds of megabytes of JSON), serialization can take longer than the computation itself. Transferable objects — ArrayBuffer, MessagePort, and ImageBitmap — bypass serialization by transferring memory ownership rather than copying it. After transferring an ArrayBuffer to a worker, the original reference becomes detached and unusable in the main thread. This zero-copy approach is essential for image processing, audio analysis, and machine learning inference workloads where data volumes make serialization impractical. Design your worker API around binary formats when performance is critical rather than trying to serialize large JavaScript objects.

Production Considerations and Resource Management

Deploying a worker thread pool in production requires careful attention to resource lifecycle management. Worker threads consume memory proportional to the Node.js runtime they each initialize — expect 30–80 MB per thread depending on your module load. This means a pool of eight workers on a 512 MB container can exhaust memory before the application even starts handling requests. Set idleTimeout to reclaim memory from idle workers during off-peak hours, and pair minThreads with horizontal scaling — keep pools small per instance and scale instances rather than growing pools indefinitely. Kubernetes liveness probes should check pool health via the utilization metric, terminating pods where all threads are perpetually busy (indicating queue buildup) before timeouts cascade to clients.

Monitoring and Observability

Piscina's histogram metrics are the most production-ready observability story of the three libraries. The pool.runTime and pool.waitTime histograms expose HDR histogram data compatible with Prometheus via the hdr-histogram-js bridge. Track the 99th percentile wait time separately from run time — a growing wait histogram indicates the pool is undersized, while a growing run histogram suggests individual tasks are getting slower (possibly due to memory pressure or dataset growth). Instrument your custom metrics alongside these: emit task-level spans using OpenTelemetry's trace.startActiveSpan from inside the worker file to trace individual CPU-bound operations end-to-end from the HTTP request that spawned them.

TypeScript Integration and Type Safety

All three libraries ship TypeScript declarations, but the developer experience differs meaningfully. Piscina's generic type parameter (new Piscina<Input, Output>) provides end-to-end type safety between the task submission call and the resolved value. Tinypool uses the same approach but drops the histogram return types since those APIs don't exist. Workerpool's pool.exec() pattern returns Promise<any> by default unless you cast the return value manually, making it the weakest TypeScript story of the three. For tinypool and piscina, co-locate worker type definitions in a shared types module imported by both the worker file and the main thread — this is the only way to ensure the input/output contract stays synchronized during refactors.

Security and Isolation Boundaries

Worker threads share memory with the main process and with each other via SharedArrayBuffer. This is a feature — zero-copy data transfer via transferable objects — but also a security consideration. A compromised or buggy worker can corrupt shared memory visible to other workers in the same pool. If you are processing untrusted user data, consider using worker_threads resourceLimits to cap CPU time and memory per worker invocation — piscina passes these through its constructor options. Never pass sensitive credentials like database connection strings directly via workerData; instead, use process.env within the worker file so the credentials are part of the worker process environment rather than serialized across the message channel where they might be logged.

Migration Paths and Ecosystem Context

Teams migrating from the legacy cluster module or child_process.fork patterns to worker threads should start with piscina due to its comprehensive API surface and Node.js team backing. The migration is straightforward for CPU-bound tasks but requires care for I/O-heavy code — worker threads excel at CPU work but add overhead compared to the event loop for pure I/O. Tinypool is the right choice when your primary motivation is matching what Vitest uses internally, enabling you to run the same test isolation patterns in your application code. Workerpool is the pragmatic choice for teams with isomorphic codebases where the same batching logic must run in browser Web Workers and Node.js worker threads without branching — its unified API absorbs the platform difference cleanly.

Debugging Worker Thread Failures in Production

When a worker thread throws an unhandled exception, the pool library catches it and rejects the task's promise — but the stack trace may reference code running inside a worker context, making it harder to correlate with the original caller. Attach structured logging inside your worker entry file to capture both the error and relevant task metadata before the exception propagates. With piscina, set workerData to include a worker ID at pool creation time, and log the worker ID alongside every error so you can correlate failures with specific pool instances. Node.js's --enable-source-maps flag works inside worker threads, so source maps from TypeScript compilation are respected when reading stack traces. Configure your error tracking service (Sentry, Datadog, etc.) inside the worker module's top-level scope rather than inside individual task functions, so uncaught errors from any task in that worker are captured regardless of whether the caller's error handler fires.

When to Use Each

Choose piscina if:

  • Production Node.js server offloading CPU work
  • Need transferable objects for zero-copy data passing
  • Want histogram metrics for monitoring task performance
  • Built by the Node.js team — battle-tested

Choose tinypool if:

  • Want the lightest worker pool option (~2 KB)
  • ESM-first project
  • Building tools similar to Vitest that need parallel execution
  • Don't need histogram metrics or transferable objects

Choose workerpool if:

  • Need browser support — Web Workers alongside Node.js worker threads
  • Prefer named function execution (pool.exec("functionName", args))
  • Want inline execution without separate worker files
  • Cross-environment library or isomorphic application

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on piscina v4.x, tinypool v1.x, and workerpool v9.x.

Compare concurrency and worker thread packages on PkgPulse →

See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.