All posts
PerformanceApr 20, 2026·6 min read

CPU-Bound vs I/O-Bound Tasks in Node.js

Why Node.js excels at I/O-bound work and struggles with CPU-bound tasks — and what to do about cryptography, image processing, and data transformation in a Node.js server.

The fundamental distinction

Every task your Node.js server performs falls into one of two categories, and understanding which category a task falls into determines how you should handle it.

I/O-bound tasks spend most of their time waiting for external systems to respond: a database query, a Redis lookup, an HTTP call to an external API, reading a file from disk. The CPU is idle during this wait. Node.js handles I/O-bound work exceptionally well because its event loop can serve other requests while any given request is waiting.

CPU-bound tasks spend most of their time executing JavaScript: image resizing, PDF generation, cryptographic operations, complex data transformations, compression of large payloads. The CPU is busy the entire time. Node's single-threaded event loop has only one CPU core at its disposal — while a CPU-bound task runs, every other request waits.

Why Node.js excels at I/O

The key insight: Node.js doesn't block during I/O. When you await a database query, Node parks that execution context and immediately begins handling the next request. When the database responds, Node resumes where it left off. This is why a single Node process can handle thousands of concurrent connections efficiently.

// These three operations happen truly concurrently —
// all three requests hit their respective services simultaneously
const [user, projects, analytics] = await Promise.all([
  prisma.user.findUnique({ where: { id: userId } }),
  prisma.project.findMany({ where: { userId } }),
  redis.get(`analytics:${userId}`),
]);
// Total time ≈ max(db1, db2, redis), not db1 + db2 + redis

// Compare: fetching sequentially is 3x slower but still non-blocking
const user = await prisma.user.findUnique({ where: { id: userId } });
const projects = await prisma.project.findMany({ where: { userId } });
const analytics = await redis.get(`analytics:${userId}`);

Where Node.js struggles: the event loop tax

A CPU-bound operation doesn't yield the event loop. Every millisecond it runs, every other pending request waits. This isn't a Node.js bug — it's a fundamental property of single-threaded execution.

// ✗ This blocks every other user for 50-200ms per request
app.post('/resize', async (req, res) => {
  // Sharp's resize is CPU-bound — even though it uses libuv threads internally
  // for the decode/encode, the JavaScript coordination still occupies the main thread
  const result = await sharp(req.body)
    .resize(1920, 1080)
    .jpeg({ quality: 85 })
    .toBuffer();
  res.send(result);
});

// Measure the event loop impact:
import { performance } from 'perf_hooks';
const before = performance.now();
// ... CPU-bound operation ...
const after = performance.now();
console.log(`Event loop blocked for ${after - before}ms`);

Identifying CPU-bound bottlenecks

Before optimizing, confirm that CPU is actually the bottleneck. Profile the event loop lag:

import { monitorEventLoopDelay } from 'perf_hooks';

const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();

setInterval(() => {
  const meanMs = histogram.mean / 1e6;
  const p99Ms = histogram.percentile(99) / 1e6;

  if (p99Ms > 50) {
    log.warn({ meanMs, p99Ms }, 'High event loop lag — possible CPU-bound work on main thread');
  }

  histogram.reset();
}, 5000);

A healthy event loop has p99 lag under 10ms. 50ms+ indicates CPU-bound work that needs to move off the main thread.

Solution 1: Worker threads for immediate offloading

Worker threads run JavaScript in parallel OS threads, each with its own V8 instance and event loop. They communicate with the main thread via message passing, which handles the data transfer safely.

// workers/image-processor.ts
import { workerData, parentPort } from 'worker_threads';
import sharp from 'sharp';

async function processImage() {
  const { buffer, width, height, quality } = workerData;

  const result = await sharp(Buffer.from(buffer))
    .resize(width, height, { fit: 'cover' })
    .jpeg({ quality })
    .toBuffer();

  parentPort!.postMessage({ result: result.buffer }, [result.buffer]);
}

processImage().catch((err) => {
  parentPort!.postMessage({ error: err.message });
});
// src/lib/run-in-worker.ts
import { Worker } from 'worker_threads';
import path from 'path';

export function runInWorker<T>(workerFile: string, data: unknown): Promise<T> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.resolve(workerFile), { workerData: data });

    worker.on('message', ({ result, error }) => {
      if (error) reject(new Error(error));
      else resolve(result);
    });

    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
    });
  });
}

Solution 2: Worker thread pool (recommended for high traffic)

Creating a new Worker for each request is expensive — each worker starts a new V8 instance (~30ms startup). Use Piscina, the standard Node.js worker pool library, to maintain a reusable pool of workers:

import Piscina from 'piscina';

const imagePool = new Piscina({
  filename: new URL('./workers/image-processor.js', import.meta.url).href,
  minThreads: 2,
  maxThreads: Math.max(2, require('os').cpus().length - 1), // Leave one CPU for the main thread
  idleTimeout: 30_000,
});

app.post('/images/resize', async (req, res) => {
  try {
    const result = await imagePool.run({
      buffer: req.body,
      width: parseInt(req.query.width as string) || 800,
      height: parseInt(req.query.height as string) || 600,
      quality: 85,
    });
    res.set('Content-Type', 'image/jpeg').send(Buffer.from(result));
  } catch (err) {
    res.status(500).json({ error: 'Image processing failed' });
  }
});

Solution 3: Move async work to a queue

For tasks that don't need to complete before responding to the user (PDF generation, email rendering, video transcoding, report generation), don't block the request at all. Accept the work, enqueue it, and return a job ID. The worker processes it asynchronously.

import { Queue } from 'bullmq';
const pdfQueue = new Queue('pdf-generation', { connection: redis });

app.post('/reports/generate', requireAuth, async (req, res) => {
  const job = await pdfQueue.add('generate', {
    reportType: req.body.reportType,
    userId: req.user.id,
    filters: req.body.filters,
  }, {
    attempts: 3,
    backoff: { type: 'exponential', delay: 1000 },
  });

  // Return immediately — don't wait for PDF generation
  res.status(202).json({
    jobId: job.id,
    status: 'queued',
    statusUrl: `/reports/jobs/${job.id}`,
  });
});

// Polling endpoint for status
app.get('/reports/jobs/:jobId', async (req, res) => {
  const job = await pdfQueue.getJob(req.params.jobId);
  if (!job) return res.status(404).json({ error: 'Job not found' });

  const state = await job.getState();
  res.json({
    jobId: job.id,
    status: state,
    result: state === 'completed' ? job.returnvalue : null,
    error: state === 'failed' ? job.failedReason : null,
  });
});

Which operations are CPU-bound vs I/O-bound?

  • Image resizing (sharp) — CPU-bound (decode/transform/encode). Use worker pool.
  • PDF generation (puppeteer, pdfmake) — CPU-bound. Use queue + worker.
  • bcrypt hashing — CPU-bound but has async API that uses libuv thread pool. The await bcrypt.hash() is non-blocking.
  • JSON.parse on large payloads (>500KB) — CPU-bound. Parse in worker thread.
  • Gzip/brotli compression — CPU-bound for large payloads. Use streams.
  • Database queries — I/O-bound. Handled correctly by event loop.
  • HTTP fetch — I/O-bound. Handled correctly by event loop.
  • File reads (fs.readFile) — I/O-bound. Use async version, never sync.
  • Crypto (AES encryption) — Use crypto.subtle which uses the thread pool.

Ready to put this into practice?

Deploy your Node.js app to production in minutes — zero YAML, automatic CI/CD, and HTTPS included.