Worker Threads en Node.js: delegar trabajo intensivo de CPU
Usa los worker threads de Node.js para tareas intensivas de CPU sin bloquear el event loop, con pools de hilos y mensajería con tipado seguro.

Node.js tiene un event loop de un solo hilo, lo cual es excelente para trabajo con mucho I/O y pésimo para trabajo intensivo de CPU. En el momento en que empiezas a hacer procesamiento de imágenes, hashing criptográfico o generación de PDF dentro de un request handler, todas las demás solicitudes quedan esperando. Los worker threads resuelven esto, y son más accesibles de lo que la mayoría cree.
El problema: bloquear el event loop
El event loop solo puede hacer una cosa a la vez. Una operación de CPU de 200 ms no solo ralentiza una solicitud: retrasa cada solicitud que está en cola detrás de ella. Con poco tráfico esto pasa desapercibido. Bajo carga, es catastrófico.
// ❌ Runs on the main thread — blocks everything while computing
export async function POST(req: Request) {
const { data } = await req.json();
const result = heavyComputation(data); // 500ms of pure CPU work
return Response.json({ result });
}
// ✅ Offloads to a worker thread — main thread stays responsive
export async function POST(req: Request) {
const { data } = await req.json();
const result = await pool.run({ type: "COMPUTE", payload: data });
return Response.json({ result });
}La solución no es usar setImmediate ni dividir el trabajo en fragmentos de microtareas (aunque eso también tiene su lugar). Para trabajo verdaderamente intensivo de CPU, la respuesta son los worker threads.
Cómo configurar un worker básico
Node.js expone los workers a través del módulo worker_threads. Un worker se ejecuta en su propia instancia de V8 con su propio event loop. La comunicación ocurre mediante el paso de mensajes: no hay memoria compartida por defecto.
// workers/compute.worker.ts
import { parentPort, workerData } from "worker_threads";
if (!parentPort) throw new Error("Must be run as a worker");
function heavyComputation(input: number[]): number {
// Simulate expensive work — sorting, hashing, parsing large datasets, etc.
return input.reduce((acc, val) => acc + Math.sqrt(val * Math.PI), 0);
}
const result = heavyComputation(workerData.input);
parentPort.postMessage({ result });// lib/run-in-worker.ts
import { Worker } from "worker_threads";
import path from "path";
export function runComputeWorker(input: number[]): Promise<number> {
return new Promise((resolve, reject) => {
const worker = new Worker(
path.resolve(__dirname, "../workers/compute.worker.js"),
{ workerData: { input } }
);
worker.on("message", ({ result }) => resolve(result));
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
});
});
}Crear un worker por cada solicitud funciona para prototipos, pero tiene un costo real: solo el arranque del hilo cuesta entre 50 y 100 ms. El patrón correcto para producción es un pool.
Cómo construir un thread pool
Crear y destruir hilos para cada tarea desperdicia el costo de arranque y genera picos de latencia. Un pool mantiene los hilos vivos y los reutiliza entre solicitudes.
// lib/worker-pool.ts
import { Worker } from "worker_threads";
import path from "path";
interface PoolTask<T> {
data: unknown;
resolve: (value: T) => void;
reject: (error: Error) => void;
}
export class WorkerPool<T = unknown> {
private idle: Worker[] = [];
private queue: PoolTask<T>[] = [];
private currentTask = new Map<Worker, PoolTask<T>>();
constructor(workerPath: string, size: number) {
for (let i = 0; i < size; i++) {
const worker = new Worker(path.resolve(workerPath));
this.idle.push(worker);
worker.on("message", (result: T) => {
const task = this.currentTask.get(worker);
if (task) {
this.currentTask.delete(worker);
task.resolve(result);
}
this.idle.push(worker);
this.drain();
});
worker.on("error", (err) => {
const task = this.currentTask.get(worker);
if (task) task.reject(err);
this.idle.push(worker);
this.drain();
});
}
}
run(data: unknown): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push({ data, resolve, reject });
this.drain();
});
}
private drain() {
if (this.queue.length === 0 || this.idle.length === 0) return;
const worker = this.idle.pop()!;
const task = this.queue.shift()!;
this.currentTask.set(worker, task);
worker.postMessage(task.data);
}
}El tamaño del pool debería coincidir con la cantidad de núcleos de CPU menos uno: deja un núcleo libre para el hilo principal y el manejo de I/O. os.cpus().length - 1 es un valor por defecto confiable para la mayoría de las cargas de trabajo en servidores.
Contratos de mensajes con tipado seguro
Los workers se comunican mediante postMessage, que por naturaleza es de tipo any. Un contrato de mensajes tipado detecta las inconsistencias en tiempo de compilación en lugar de en tiempo de ejecución en producción.
// types/worker-messages.ts
export type WorkerRequest =
| { type: "HASH"; payload: { data: string; algorithm: "sha256" | "sha512" } }
| { type: "COMPRESS"; payload: { buffer: ArrayBuffer; level: number } }
| { type: "PARSE_CSV"; payload: { csv: string; delimiter: string } };
export type WorkerResponse =
| { type: "HASH"; result: string }
| { type: "COMPRESS"; result: ArrayBuffer }
| { type: "PARSE_CSV"; result: Record<string, string>[] };
// workers/multipurpose.worker.ts
import { parentPort } from "worker_threads";
import type { WorkerRequest, WorkerResponse } from "../types/worker-messages";
parentPort?.on("message", (req: WorkerRequest) => {
let response: WorkerResponse;
switch (req.type) {
case "HASH":
response = { type: "HASH", result: hashData(req.payload) };
break;
case "COMPRESS":
response = { type: "COMPRESS", result: compress(req.payload) };
break;
case "PARSE_CSV":
response = { type: "PARSE_CSV", result: parseCsv(req.payload) };
break;
}
parentPort?.postMessage(response);
});El switch exhaustivo de TypeScript generará un error en tiempo de compilación si agregas un nuevo tipo de mensaje sin manejarlo en el worker. Esa es exactamente la seguridad que quieres tener entre límites de hilos asíncronos.
Pasa los valores de ArrayBuffer como objetos Transferable usando el
segundo argumento de postMessage(data, [buffer]). Esto transfiere la
propiedad en lugar de clonar los datos: el costo de copia es cero sin importar
el tamaño del payload.
Cuándo no usar worker threads
Los worker threads no son gratis. La clonación estructurada en postMessage es O(n) respecto al tamaño del payload. Para buffers grandes, usa SharedArrayBuffer o transferencia de propiedad. Para payloads pequeños, el overhead puede superar por completo el beneficio.
| Caso de uso | ¿Worker threads? | Motivo |
|---|---|---|
| Procesamiento de imágenes / video | Sí | Uso intensivo de CPU, los buffers son transferibles |
| Hashing criptográfico | Sí | Intensivo de CPU, payloads pequeños |
| Inferencia de ML (ONNX, WASM) | Sí | De larga duración, intensivo de CPU o WASM |
| Consultas a bases de datos | No | Limitado por I/O, los drivers async ya lo resuelven |
| Llamadas a APIs externas | No | Limitado por I/O, sin trabajo de CPU involucrado |
| Parseo de JSON (< 1 MB) | No | El overhead supera el beneficio |
| Parseo de JSON (> 10 MB) | Sí | El costo de CPU domina a gran escala |
El umbral práctico: si perf_hooks muestra que tu handler pasa más de 10 ms en trabajo síncrono de CPU, es candidato para un worker thread.
Uniendo todo en una ruta real
// app/api/image/route.ts
import { WorkerPool } from "@/lib/worker-pool";
import type { WorkerResponse } from "@/types/worker-messages";
import os from "os";
// Initialize once at module load — not per request
const pool = new WorkerPool<WorkerResponse>(
require.resolve("@/workers/image-processor.worker"),
Math.max(1, os.cpus().length - 1)
);
export async function POST(req: Request) {
const formData = await req.formData();
const file = formData.get("image") as File | null;
if (!file) {
return Response.json({ error: "No file provided" }, { status: 400 });
}
const buffer = await file.arrayBuffer();
try {
const response = await pool.run({
type: "COMPRESS",
payload: { buffer, level: 8 },
}) as Extract<WorkerResponse, { type: "COMPRESS" }>;
return new Response(response.result, {
headers: { "Content-Type": "image/webp" },
});
} catch (err) {
console.error("[image-worker] processing failed:", err);
return Response.json({ error: "Processing failed" }, { status: 500 });
}
}El pool se crea una sola vez al inicializar el módulo y se reutiliza en cada solicitud. Los workers se mantienen activos. El event loop permanece libre. Las solicitudes que exceden la capacidad se encolan automáticamente y se procesan a medida que hay hilos disponibles.
Puntos clave
- El trabajo intensivo de CPU bloquea cada solicitud — una sola operación síncrona de 200 ms genera picos de latencia de cola en todas las solicitudes concurrentes bajo carga
- Crea un pool, no workers individuales — crear un worker por solicitud anula el propósito; reutiliza los hilos y paga el costo de arranque una sola vez
- Transfiere los buffers grandes, no los clones — pasa
ArrayBuffercomo unTransferablepara evitar el costo de serialización en cada mensaje - Tipa tus contratos de mensajes —
postMessagees de tipoanypor defecto; las uniones discriminadas dan seguridad en tiempo de compilación entre límites de hilos - Dimensiona el pool según tus núcleos —
os.cpus().length - 1es el valor correcto por defecto; deja espacio para el event loop y el I/O - Perfila antes de optimizar — los worker threads agregan complejidad real; recurre a ellos solo cuando puedas medir el bloqueo del event loop con
perf_hookso clinic.js


