Image encodes belong in a separate worker process because they take over threads your API needs for other work. I ran 16 AVIF encodes inside a Fastify API. A health check stayed fast, but a sign-in request that hashes a password waited 11,028.0 ms in the slowest case. With the same encodes handed to a BullMQ worker, the slowest sign-in took 53.9 ms. The upload request itself answered in 12 ms instead of 8,985 ms.
Isn't sharp already async?
Yes, and that is the trap. sharp does not block the event loop. My /health route answered with a median of 1.5 ms during all 16 encodes. If you only watch event-loop lag, everything looks fine.
The cost lands somewhere else. sharp's tasks wait for a thread from libuv, and the sharp utility docs describe that queue. libuv's pool also runs Node's fs calls, dns.lookup(), zlib and async crypto such as crypto.pbkdf2(). The Node.js docs put its default size at 4 and warn that one slow user of the pool slows every other API that shares it.
A password check is one of those APIs. So is reading a file or resolving a hostname.
What did I run?
One Fastify API with four routes. /inline encodes in the request. /queued adds a BullMQ job and answers 202. /login runs pbkdf2 with 100,000 iterations, like a sign-in. /health returns a constant.
import Fastify from 'fastify';
import sharp from 'sharp';
import { Queue } from 'bullmq';
import { readFile } from 'node:fs/promises';
import { pbkdf2 } from 'node:crypto';
import { promisify } from 'node:util';
const connection = { host: '127.0.0.1', port: Number(process.env.REDIS_PORT) };
const queue = new Queue('encode', { connection });
const photo = await readFile('photo.jpg');
const app = Fastify();
app.get('/health', async () => ({ ok: true }));
// A sign-in check: pbkdf2 runs on the libuv thread pool, the same pool sharp uses
app.get('/login', async () => {
await promisify(pbkdf2)('password', 'salt', 100_000, 32, 'sha256');
return { ok: true };
});
// A: encode inside the request
app.post('/inline', async () => {
const out = await sharp(photo).avif({ quality: 50 }).toBuffer();
return { bytes: out.length };
});
// B: hand the encode to a worker, answer at once
app.post('/queued', async (req, reply) => {
const job = await queue.add('avif', { path: 'photo.jpg', quality: 50 }, { removeOnComplete: 1000 });
return reply.code(202).send({ jobId: job.id });
});
await app.listen({ port: 4317 });
The worker is its own Node process:
import { Worker } from 'bullmq';
import sharp from 'sharp';
const connection = { host: '127.0.0.1', port: Number(process.env.REDIS_PORT) };
sharp.concurrency(1); // one libvips thread per job; the worker's concurrency sets the parallelism
new Worker('encode', async (job) => {
const out = await sharp(job.data.path).avif({ quality: job.data.quality }).toBuffer();
return { bytes: out.length };
}, { connection, concurrency: 2 });
The benchmark fires 16 POSTs at once and probes /login or /health every 20 ms until they finish. Each scenario ran 5 times. The photo is a 1920 × 1280 JPEG of 576,828 bytes; one AVIF q50 encode of it is 118,975 bytes.
What happened to the sign-in?
One of the five runs, as it printed:
idle login | probe: median 18.2 ms, p95 52.1 ms, worst 52.1 ms, 20 probes
inline login | POST answered: median 10036 ms, slowest 14105 ms | probe: median 21.6 ms, p95 13572.8 ms, worst 13572.8 ms, 11 probes
queued login | POST answered: median 31 ms, slowest 36 ms | probe: median 68.9 ms, p95 68.9 ms, worst 68.9 ms, 1 probes
Across the 5 runs:
| Measure | Idle | 16 encodes inline | 16 encodes queued |
|---|---|---|---|
| Sign-in, median of the run medians | 16.3 ms | 18.8 ms | n/a |
| Sign-in, median of the run worsts | 52.1 ms | 11,028.0 ms | 53.9 ms |
| Upload POST, median of the run medians | n/a | 8,985 ms | 12 ms |
/health, median of the run worsts |
34.2 ms | 53.7 ms | 36.5 ms |
The worst case is the one that matters. A sign-in that arrives just after a burst of uploads waits for the pool to drain. Eleven seconds is longer than many login forms and proxies wait.
Note: Raising
UV_THREADPOOL_SIZEdoes not fix this. It moves the line: more threads for the encodes, and the CPU is still busy. And Node's docs say setting it from inside the process "is not guaranteed to work", because the pool is created at startup.
Why did the queued uploads answer in 12 ms?
/queued does one Redis write and returns a job id. The client polls, or you push the result over SSE or a webhook. The encode still takes its time, but no request holds a socket open for it.
The cost moves to the worker. With concurrency: 2 and one libvips thread per job, the worker drained 16 queued encodes in a median of 23.7 s over 4 runs. Inline, the API used every core and finished the last one in 9.5 to 16.2 s. You trade raw finish time for an API that stays responsive, and you scale the worker on its own.
Is BullMQ's concurrency enough for CPU work?
Not alone. The BullMQ concurrency guide says the concurrency option helps asynchronous work, and points CPU-intensive jobs to sandboxed processors. It also recommends several workers as the main way to scale.
sharp is a special case. Its encode runs in native threads, not on the JavaScript thread, so concurrency does give parallel encodes inside one worker. What you must decide is the product of the two: worker concurrency × sharp.concurrency() threads, plus the extra threads libaom starts for AVIF, which sharp's docs say its setting does not control. Keep that product near your core count.
What does the rest of the setup need?
-
Timeouts per job.
sharp().timeout({ seconds })stops one bad file from holding a worker. - Retries with backoff for transient storage errors, not for files that fail to decode.
- Results in object storage, keyed by job id, so any API instance can serve the status.
- A dead-letter check. A job that fails three times needs a human, not a fourth try.
That is the shape Blinkimg runs: the image compressor posts a job, a worker encodes, and the page reads the result by id.
How I measured
- Apple Silicon Mac (M1 Pro, 8 cores), 2026-10-17. Other processes were running; the load average was high, so treat the times as relative.
- Node 24.22.0, Fastify 5.12.5, BullMQ 5.80.9, sharp 0.35.4 with libvips 8.18.6, Redis from
redis-memory-server0.17.1. - Each scenario ran 5 times; the table gives medians across runs. The drain time excludes the first run, which also drained the backlog the queued benchmarks left.
- Photo:
jpg-sample-photo-original.jpg, 1920 × 1280, 576,828 bytes.
What I did not test
I did not test a Linux server, a sandboxed BullMQ processor, or more than one worker process. I did not try fs or dns.lookup() as the probe; pbkdf2 stands in for them.
I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

Top comments (0)