A clean way to process uploaded images is to separate the request lifecycle from the expensive work.
The upload endpoint accepts files, stores metadata, places jobs on a queue and immediately returns a batchId.
The client then listens for progress updates over Server-Sent Events.
Architecture
The main parts are:
- Express API for upload and status endpoints.
- Multer for handling multipart image uploads.
- BullMQ + Redis for durable queueing.
- Worker Threads for CPU-heavy image resizing.
- Sharp for image manipulation.
- SSE for progress updates.
import express from 'express';
import multer from 'multer';
import { Queue } from 'bullmq';
const app = express();
const upload = multer({ dest: 'uploads/' });
const imageQueue = new Queue('image-processing', {
connection: { host: 'localhost', port: 6379 }
});
app.post('/api/images', upload.array('images', 2), async (req, res) => {
const batchId = crypto.randomUUID();
const files = req.files as Express.Multer.File[];
await Promise.all(
files.map((file) =>
imageQueue.add('resize', {
batchId,
filename: file.filename,
path: file.path,
size: { width: 100, height: 100 }
})
)
);
res.status(202).json({ batchId });
});
Why SSE works well here
SSE is a good fit when the server needs to push one-way progress events to the browser. The browser opens one connection and the backend streams status messages as text events.
app.get('/api/images/:batchId/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const send = (event: string, data: unknown) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
send('queued', { batchId: req.params.batchId });
});
This keeps the upload API fast and makes the processing pipeline observable from the client.