Resizing Images with createImageBitmap in Workers
Uploading photographs from a phone means decoding 12-megapixel JPEGs. Done with an Image element on the main thread, each one is a 100–200 ms block, and a batch of twenty freezes the page for several seconds.
createImageBitmap moves both the decode and the resize into a worker, and its resize options do the scaling during decode rather than after it — which is faster and higher quality than drawing to a canvas afterwards. This guide is part of Image Processing in Workers, under High-Performance Computation Patterns.
The Whole Pipeline
// thumbnail.worker.ts
interface Request { id: number; file: File; maxEdge: number }
self.addEventListener('message', async (event: MessageEvent<Request>) => {
const { id, file, maxEdge } = event.data;
// 1. Peek at the dimensions without decoding the full image.
const probe = await createImageBitmap(file, { resizeWidth: 1, resizeQuality: 'pixelated' });
const ratio = probe.width / probe.height; // (1×n after the probe; see note below)
probe.close();
// 2. Decode AND downscale in one step — the resize happens during decode.
const target = ratio >= 1
? { resizeWidth: maxEdge }
: { resizeHeight: maxEdge };
const bitmap = await createImageBitmap(file, { ...target, resizeQuality: 'high' });
// 3. Draw into an OffscreenCanvas to obtain an encodable surface.
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
canvas.getContext('2d')!.drawImage(bitmap, 0, 0);
bitmap.close(); // release the decoded pixels immediately
// 4. Encode, and transfer the bytes rather than cloning them.
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: 0.82 });
self.postMessage({ id, blob, width: canvas.width, height: canvas.height });
});
The main thread’s total involvement is posting a File — which is cloneable without copying its contents — and receiving a Blob. Decode, resize and encode all happen on the worker.
The Options That Matter
resizeWidth / resizeHeight. Supplying one lets the browser scale during decode and preserves the aspect ratio; supplying both stretches. Scaling at decode time means the full-resolution bitmap is never materialised, so peak memory is the output size rather than 48 MB for a 12-megapixel source.
resizeQuality. 'pixelated', 'low', 'medium', 'high'. The difference is visible on photographic downscales beyond about 2×: 'low' is a box filter with visible aliasing on fine detail, 'high' is a proper multi-step filter. The cost difference is roughly 10–25% of decode time — worth paying for anything a user will look at.
imageOrientation: 'from-image'. Applies the EXIF orientation tag, which phone cameras use constantly. Without it, portrait photographs from many devices appear rotated, and this is the single most common bug report in upload pipelines.
premultiplyAlpha and colorSpaceConversion. Leave both at their defaults unless doing pixel maths on the result. colorSpaceConversion: 'none' skips a conversion pass and is meaningfully faster, at the cost of colours that do not match the source’s embedded profile.
Decoding a 4000×3000 JPEG to full size and then drawing it small costs about 145 ms and holds 48 MB of decoded pixels. Passing resizeWidth: 400 costs about 95 ms and holds 0.5 MB. The saving is larger on memory-constrained devices, where the full-size decode is also what triggers a tab kill.
Running It Over a Pool
Thumbnailing is embarrassingly parallel — each image is independent — so it is the ideal workload for a pool sized to the hardware:
// page.ts
const pool = new Pool({
size: Math.min(4, navigator.hardwareConcurrency ?? 4), // decode is memory-hungry; cap it
create: () => new Worker(new URL('./thumbnail.worker.ts', import.meta.url), { type: 'module' }),
});
const thumbnails = await Promise.all(
files.map((file) => pool.run<{ blob: Blob }>({ file, maxEdge: 400 })));
Cap the pool lower than you would for pure computation. Each in-flight decode holds its decoded pixels, so eight concurrent 12-megapixel decodes is nearly 400 MB of transient memory even with resize-at-decode — enough to be killed on a mid-range phone. Four is a good ceiling for photographic work; the general sizing discussion is in Worker Pool Management.
Transferring Bitmaps Instead of Pixels
An ImageBitmap is transferable, which makes it the right currency between threads when the destination is a canvas rather than a byte array:
// worker: hand the bitmap itself to the page — no pixel copy
self.postMessage({ id, bitmap }, [bitmap]);
// page: draw it directly; a bitmap is a valid drawImage source
context.drawImage(event.data.bitmap, 0, 0);
event.data.bitmap.close(); // the page now owns it, and must release it
Compare that with extracting ImageData and posting the Uint8ClampedArray: a 400×400 thumbnail is 640 kB of pixels, and the conversion itself costs a getImageData call. Transferring the bitmap moves a handle. The exception is when the page needs the pixels — for a hash, a histogram, a diff — in which case posting a transferred ImageData buffer is correct, as described in Using Transferable Objects for Canvas ImageData.
Choosing the Output Format and Size
Two decisions after the resize affect the result more than any of the decode options.
Format. WebP at quality 0.8 is roughly 25–35% smaller than JPEG at visually equivalent quality and encodes at a similar speed, which makes it the sensible default for thumbnails. PNG is right only for images with sharp edges or transparency — a photograph encoded as PNG is typically 5–10× larger. AVIF compresses better still but encodes slowly enough in the browser that a batch of twenty becomes noticeably slower, so it is better applied server-side.
Dimensions. Generate for the largest slot the thumbnail can occupy multiplied by the device pixel ratio, and no larger. A 400 px display slot on a 3× phone needs 1200 px; producing 2000 px “to be safe” costs 2.8× the pixels in every subsequent operation, including the upload. Where several sizes are needed, generate them from the same decoded bitmap rather than decoding the source repeatedly:
const source = await createImageBitmap(file, { imageOrientation: 'from-image' });
const sizes = [1200, 600, 200];
const outputs = await Promise.all(sizes.map(async (edge) => {
const scaled = await createImageBitmap(source, { resizeWidth: edge, resizeQuality: 'high' });
const canvas = new OffscreenCanvas(scaled.width, scaled.height);
canvas.getContext('2d')!.drawImage(scaled, 0, 0);
scaled.close();
return canvas.convertToBlob({ type: 'image/webp', quality: 0.82 });
}));
source.close();
createImageBitmap accepts an existing bitmap as its source, so the expensive decode happens once and the three scales are cheap resamples. This is the single largest saving available in a multi-size pipeline, and it is easy to miss because the naive version — three calls against the File — looks equally tidy and costs three full decodes.
Gotchas
Not calling close(). Decoded bitmap memory sits outside the JavaScript heap, so a leak here is invisible in a heap snapshot and shows only in the process’s memory. Close every bitmap the moment it has been drawn or transferred.
Probing dimensions by decoding twice. The resizeWidth: 1 probe in the example is cheap but not free. If the source is an upload with EXIF, read the dimensions from the header instead; if the target is a fixed box, pass both resizeWidth and resizeHeight with resizeQuality: 'high' and accept a fitted rather than a stretched result by computing the ratio server-side.
Forgetting imageOrientation. Portrait phone photographs come out sideways, and only on some devices, which makes it look like a device-specific bug rather than a missing option.
Assuming convertToBlob supports every format. WebP and PNG are broadly available; AVIF encoding is not universal, and an unsupported type silently falls back to PNG, which can be several times larger than expected.
Ignoring animated sources. createImageBitmap on an animated GIF or WebP yields the first frame only, silently. If animation matters, decode with ImageDecoder where available, and otherwise detect the format and route those files down a different path rather than shipping a thumbnail that looks static by accident.
Decoding on the main thread by accident. createImageBitmap works on the main thread too, and doing the decode there defeats the purpose. The call must be inside the worker, with the File or Blob sent across.
Sending File objects one at a time from a slow picker. Reading twenty files from a directory handle can itself take hundreds of milliseconds on the main thread. Pass the handles or the FileList to the worker and let it call getFile() there, so even the file access is off the main thread.
Encoding at quality 1.0. WebP and JPEG at maximum quality are several times larger than at 0.8 with no visible difference on a thumbnail, and the encode itself is slower. Treat 0.8–0.85 as the default and raise it only for images that will be zoomed.
Performance Note
Twenty 12-megapixel JPEGs to 400 px WebP thumbnails on a 2023 laptop: main thread with Image and canvas took 3.3 s of fully blocked interface. A four-worker pool with resizeWidth at decode finished in 0.9 s wall clock with 12 ms of main-thread time — and peak transient memory fell from roughly 190 MB to 58 MB, which is what makes the same code survive on a mid-range phone.
In short: decode once, scale during the decode, close every bitmap, and cap the pool below what a phone can hold. Those four rules cover almost every thumbnail pipeline.