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.

Main-thread cost of two thumbnail pipelines Two pipelines for twenty twelve-megapixel photographs. The main-thread pipeline using an Image element and a canvas blocks for about 140 milliseconds per image in decode and a further 25 milliseconds in draw and encode, totalling more than three seconds of frozen interface. The worker pipeline using createImageBitmap with resize options spends the same work on the worker thread while the main thread only posts twenty file references and receives twenty blobs, for about 12 milliseconds of main-thread time in total. Twenty 12-megapixel photos, main-thread cost Image + canvas decode 20 × 140 ms — the page is frozen draw + encode ≈ 3.3 s of blocked main thread worker + bitmap ≈ 12 ms — post 20 File refs, receive 20 Blobs the same decode and resize work, on the worker thread ×4 with a pool 0 1.7 s 3.4 s The total work is unchanged. What changes is which thread is unavailable while it happens.
Image decoding is the largest single-purpose block of main-thread time in most upload flows, and it is entirely avoidable.

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.

Resize at decode, not after

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.

Transient memory against pool size for 12-megapixel decodes A comparison of peak transient memory at four pool sizes when decoding twelve-megapixel photographs. With resize at decode, each in-flight image holds a small output bitmap plus its compressed source, so two workers peak near 30 megabytes, four near 60, and eight near 120. Without resize at decode, each in-flight image materialises 48 megabytes of full-resolution pixels first, so two workers peak near 100 megabytes, four near 200, and eight near 400 — a level at which mid-range mobile devices terminate the tab. Peak transient memory while decoding 2 workers 4 workers 8 workers 0 200 MB 400 MB resize at decode full decode, then draw The pumpkin bar at eight workers is where mid-range phones terminate the tab — a crash with no JavaScript error and no stack.
Two variables control the same ceiling. Resize-at-decode is the larger lever, and the pool cap is the guard rail.

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.


The createImageBitmap options that matter, and what each one costs Four options. resizeWidth or resizeHeight scales during decode, so the full-resolution bitmap is never materialised. resizeQuality high costs ten to twenty-five percent more decode time and removes visible aliasing on photographic downscales. imageOrientation from-image applies the EXIF rotation that phone cameras rely on. colorSpaceConversion none skips a conversion pass and is faster, at the cost of colours that no longer match an embedded profile. Four options, and what each one buys resizeWidth or resizeHeight scales during decode — the full-size bitmap never exists resizeQuality: high 10–25% more decode time; removes aliasing on photos imageOrientation: from-image applies EXIF rotation — without it, phone portraits come out sideways colorSpaceConversion: none skips a pass; colours no longer match an embedded profile Only the first changes the memory ceiling. The third is the one that produces bug reports.
Two of these are performance settings and two are correctness settings — worth knowing which is which.

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.

Frequently Asked Questions

Does createImageBitmap decode the image on the worker thread?
Yes. Given a Blob or File, createImageBitmap performs the full decode off the main thread and resolves with a GPU-friendly bitmap, which is the main reason to use it rather than an Image element. The decode of a 12-megapixel JPEG costs 80–200 ms on a mid-range device — time the main thread never spends when a worker owns the call.
Do I have to call close() on an ImageBitmap?
Yes for anything you create in bulk. An ImageBitmap holds decoded pixel memory — roughly width × height × 4 bytes — outside the JavaScript heap, so it is invisible in a heap snapshot and is not collected promptly. A gallery that decodes two hundred images without closing them can hold several gigabytes while the heap looks small.

See also