Skip to content
ExponaBLOG
NESTJS · DAY 073 / 100

Worker threads for CPU-heavy work for NestJS developers

Keep the event loop free for requests.

Expona Admin9 min read

Worker threads for CPU-heavy work

Node.js runs on a single-threaded event loop, which is brilliant for I/O operations but becomes a bottleneck when you need to process image data, generate PDFs, or parse large files. Worker threads let you offload these CPU-bound tasks to separate threads, keeping your event loop free to handle incoming requests and maintain low latency for your users.

The event loop bottleneck

When you perform CPU-intensive work directly in your NestJS request handler, you're blocking the event loop. Every millisecond spent crunching numbers is a millisecond where other requests sit waiting. I learned this the hard way early in my career—a single endpoint that resized images could tank the response time for the entire API.

Consider this naive approach:

typescript
@Post('resize-image')
async resizeImage(@Body('imageBuffer') buffer: Buffer) {
  const resized = await heavyImageResize(buffer, 1920, 1080);
  return { success: true, data: resized };
}

function heavyImageResize(buffer: Buffer, width: number, height: number): Promise<Buffer> {
  return new Promise(resolve => {
    // Imagine 500ms of pixel manipulation here
    for (let i = 0; i < 100_000_000; i++) {
      Math.sqrt(i);
    }
    resolve(buffer);
  });
}

If that operation takes 500ms, every other request in the queue waits 500ms longer. With worker threads, you push this work to a background thread and free up the main thread immediately.

Image processing and PDF generation use cases

The most common CPU-bound operations in web applications are image processing and PDF generation. A user uploads a photo and expects thumbnails generated instantly. An admin clicks "export report to PDF" and wants it in seconds, not minutes.

In one of my e-commerce platforms, we had to generate product thumbnails in five different sizes whenever an image was uploaded. Using Sharp (a popular image processing library) directly in the request handler caused request timeouts and angry customers. Once we moved that work to a worker pool, response times dropped from 2+ seconds to under 100ms—the user got an immediate acknowledgment while the thumbnails were generated in the background.

PDF generation is even more intensive. Libraries like PDFKit or Puppeteer (for rendering HTML to PDF) can consume significant CPU time. If you're generating invoices or reports on demand, a worker thread keeps your API responsive.

Using Piscina for thread pooling

Piscina is a lightweight, fast worker thread pool for Node.js. Instead of spawning a new thread for every task, you maintain a pool of reusable threads that pick up jobs from a queue.

First, install Piscina:

typescript
npm install piscina

Create a worker file that performs the heavy computation:

typescript
// image-worker.ts
import sharp from 'sharp';

export async function resizeImage(
  buffer: Buffer,
  width: number,
  height: number
): Promise<Buffer> {
  return sharp(buffer)
    .resize(width, height, { fit: 'cover' })
    .toBuffer();
}

export async function generateThumbnails(
  buffer: Buffer
): Promise<{ sm: Buffer; md: Buffer; lg: Buffer }> {
  const sm = await sharp(buffer).resize(100, 100).toBuffer();
  const md = await sharp(buffer).resize(300, 300).toBuffer();
  const lg = await sharp(buffer).resize(800, 800).toBuffer();
  return { sm, md, lg };
}

Now use it in your NestJS service:

typescript
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import Piscina from 'piscina';
import { join } from 'path';

@Injectable()
export class ImageProcessingService implements OnModuleInit, OnModuleDestroy {
  private piscina: Piscina;

  onModuleInit() {
    this.piscina = new Piscina({
      filename: join(__dirname, 'image-worker.js'),
      minThreads: 2,
      maxThreads: 8,
    });
  }

  async onModuleDestroy() {
    await this.piscina.destroy();
  }

  async resizeImage(buffer: Buffer, width: number, height: number): Promise<Buffer> {
    return this.piscina.run({ buffer, width, height }, { name: 'resizeImage' });
  }

  async generateThumbnails(buffer: Buffer) {
    return this.piscina.run({ buffer }, { name: 'generateThumbnails' });
  }
}

In your controller, you can now call this service and return immediately:

typescript
@Post('upload-image')
async uploadImage(@Body('imageBuffer') buffer: Buffer) {
  const jobId = generateUuid();
  
  // Fire and forget—don't await
  this.imageProcessingService.generateThumbnails(buffer)
    .then(result => {
      // Store result in cache or database
      this.cache.set(`thumbnails:${jobId}`, result);
    })
    .catch(error => {
      this.logger.error('Thumbnail generation failed', error);
    });

  return { jobId, status: 'processing' };
}

@Get('thumbnails/:jobId')
async getThumbnails(@Param('jobId') jobId: string) {
  const result = this.cache.get(`thumbnails:${jobId}`);
  if (!result) {
    return { status: 'still processing' };
  }
  return result;
}

Transferable buffers for zero-copy performance

When you pass a Buffer to a worker thread, Node.js normally copies the entire buffer. For large images or files, this copy overhead is significant. Transferable objects let you move ownership of the buffer to the worker thread without copying.

Modified worker code:

typescript
// image-worker-optimized.ts
import { parentPort } from 'worker_threads';
import sharp from 'sharp';

parentPort.on('message', async (message) => {
  const { buffer, width, height, transferList } = message;
  
  try {
    const resized = await sharp(buffer).resize(width, height).toBuffer();
    
    // Transfer the result buffer back
    parentPort.postMessage(
      { success: true, data: resized },
      [resized.buffer]
    );
  } catch (error) {
    parentPort.postMessage({ success: false, error: error.message });
  }
});

When calling from the main thread, specify the transferable buffers:

typescript
const resized = await new Promise((resolve, reject) => {
  const worker = new Worker('image-worker-optimized.js');
  
  worker.on('message', (message) => {
    if (message.success) {
      resolve(message.data);
    } else {
      reject(new Error(message.error));
    }
  });

  // Transfer buffer ownership
  worker.postMessage(
    { buffer, width, height },
    [buffer.buffer]
  );
});

This approach is excellent for memory-constrained environments or when processing very large files.

Offloading to a queue worker process

For even heavier workloads, or when you want to scale independently, run worker processes separately from your main API. Use a queue system like Bull (built on Redis) to decouple the request from the processing.

Install dependencies:

typescript
npm install bull redis

Set up a queue in your NestJS module:

typescript
import { BullModule } from '@nestjs/bull';

@Module({
  imports: [
    BullModule.registerQueue({
      name: 'image-processing',
      redis: { host: 'localhost', port: 6379 },
    }),
  ],
  providers: [ImageProcessingService],
})
export class ImageModule {}

Enqueue the job instead of processing directly:

typescript
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';

@Injectable()
export class ImageProcessingService {
  constructor(@InjectQueue('image-processing') private queue: Queue) {}

  async queueThumbnailGeneration(buffer: Buffer, userId: string) {
    const jobId = generateUuid();
    
    await this.queue.add(
      'generate-thumbnails',
      { buffer: buffer.toString('base64'), userId, jobId },
      { jobId, removeOnComplete: true }
    );

    return { jobId, status: 'queued' };
  }
}

In your controller:

typescript
@Post('upload-image')
async uploadImage(@Body('imageBuffer') buffer: Buffer, @Req() req) {
  const result = await this.imageProcessingService.queueThumbnailGeneration(
    buffer,
    req.user.id
  );
  return result;
}

Create a separate worker process (or even a separate service) that consumes these jobs:

typescript
import { Injectable } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bull';
import { Job } from 'bull';
import sharp from 'sharp';

@Processor('image-processing')
@Injectable()
export class ImageWorker extends WorkerHost {
  async process(job: Job) {
    const { buffer: base64Buffer, userId, jobId } = job.data;
    const buffer = Buffer.from(base64Buffer, 'base64');

    const sm = await sharp(buffer).resize(100, 100).toBuffer();
    const md = await sharp(buffer).resize(300, 300).toBuffer();
    const lg = await sharp(buffer).resize(800, 800).toBuffer();

    // Store in database or cache
    await this.storeResults({ sm, md, lg }, userId, jobId);

    return { success: true, jobId };
  }

  @OnWorkerEvent('failed')
  onFailed(job: Job, err: Error) {
    console.error(`Job ${job.id} failed:`, err.message);
  }
}

This approach scales beautifully—you can run multiple worker instances, add new workers on demand, and your API remains responsive at all times.

Common mistakes

Mistake 1: Not cleaning up worker threads. Always destroy your Piscina instance when the module destroys. Orphaned threads consume memory and system resources.

Mistake 2: Serializing large objects unnecessarily. When passing buffers through Piscina, avoid converting to JSON unless you must. Use the transferable buffer approach or keep data as binary.

Mistake 3: Creating a new worker for every task. Worker creation has overhead. Always use a pool (Piscina) or a queue system. Never spawn a new Worker in a hot loop.

Mistake 4: Ignoring error handling in background jobs. If a thumbnail generation fails silently, the user waits forever for a result that never comes. Always log, retry, and notify.

Mistake 5: Choosing the wrong approach for your scale. For occasional, small tasks, Piscina is overkill—just use worker_threads directly. For high volume or distributed systems, use a queue. Pick the tool that matches your problem.

Key takeaways

  • Worker threads free the event loop from CPU-bound work like image processing and PDF generation, keeping your API responsive.
  • Use Piscina for in-process pooling—it's lightweight and fast for moderate workloads, and code stays in one place.
  • Transferable buffers eliminate copy overhead when moving large data to workers; use them for memory efficiency.
  • A queue-based worker process is your best option for heavy, high-volume, or distributed workloads—it decouples processing from requests and scales independently.
  • Always clean up resources, handle errors in background jobs, and match your tool choice to the scale and latency requirements of your application.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles