Image Upload & Thumbnails Tutorial · Module 03 of 11

Async Thumbnail Generation

Generate thumbnails asynchronously using Bull + Redis job queue. Create multiple sizes (small 150x150, medium 300x300, large 600x600). Use Sharp for fast image processing. Return job ID immediately to client, notify when ready via webhook or polling. By the end, thumbnails are generated in the background without blocking uploads.

~4–5 hrsIntermediateProcessing focus
← Back to Module 03 overview
What You'll Have at the End

Definition of Done

  • Bull queue for thumbnail generation jobs.
  • Generate 3 sizes: 150x150, 300x300, 600x600 using Sharp.
  • Store thumbnails alongside originals.
  • GET /images/:id returns original + all thumbnail URLs.
  • GET /jobs/:jobId shows job status and progress.
  • Handle failures: retry failed jobs, log errors.
  • Performance: generate 3 thumbnails in < 2 seconds per image.
The Steps

Build It

STEP 1

Create thumbnail generation worker

Install: npm install bull ioredis

Create src/workers/thumbnailWorker.ts:

import Queue from 'bull';
import sharp from 'sharp';
import fs from 'fs/promises';
import path from 'path';
import pool from '../db/pool';

const thumbnailQueue = new Queue('thumbnails', process.env.REDIS_URL);

const SIZES = {
  small: { width: 150, height: 150 },
  medium: { width: 300, height: 300 },
  large: { width: 600, height: 600 }
};

thumbnailQueue.process(async (job) => {
  const { imageId, imagePath } = job.data;

  try {
    for (const [size, dimensions] of Object.entries(SIZES)) {
      const thumbPath = imagePath.replace(
        '/originals/',
        `/thumbnails/${size}/`
      );

      await fs.mkdir(path.dirname(thumbPath), { recursive: true });

      await sharp(imagePath)
        .resize(dimensions.width, dimensions.height, {
          fit: 'cover',
          position: 'center'
        })
        .toFile(thumbPath);

      await pool.query(
        'INSERT INTO thumbnails (image_id, size, path) VALUES ($1, $2, $3)',
        [imageId, size, thumbPath]
      );

      job.progress((Object.keys(SIZES).indexOf(size) + 1) / Object.keys(SIZES).length * 100);
    }

    return { success: true };
  } catch (err) {
    throw err;
  }
});

export default thumbnailQueue;