When a user drops forty product photos onto an upload page, decoding and resizing all of them at the same moment can freeze the tab and spike memory. Processing them one by one is safe but slow. A bounded concurrency queue sits between those extremes: a fixed number of images are processed at a time, every file gets its own success or failure result, and the user can cancel the remaining work.
This tutorial builds that queue in TypeScript and pairs it with a real thumbnail task that uses createImageBitmap and OffscreenCanvas. The two parts are separate on purpose. The queue knows nothing about images, so you can test it in Node, and the image task can be checked in a browser.
The behavior the queue must provide
Before writing code, write down the behavior you will test:
- No more than
limittasks run at the same time. - Every input item produces exactly one outcome, in the original order.
- A failing item is recorded and does not stop the other items.
- After cancellation, no new task starts; items that never started are reported as rejected with the cancellation reason.
- The function returns only after every started task has settled, so the caller never receives a half-filled result list.
The fifth point is where many quick implementations go wrong. A queue that only starts new work from a callback can return early, or stop once the first batch finishes.
The implementation
Save this as queue.ts:
export type Outcome<T, R> =
| { item: T; status: 'fulfilled'; value: R }
| { item: T; status: 'rejected'; reason: unknown };
export type Task<T, R> = (item: T, signal: AbortSignal) => Promise<R>;
export async function runBounded<T, R>(
items: readonly T[],
limit: number,
task: Task<T, R>,
signal: AbortSignal = new AbortController().signal,
onSettled?: (outcome: Outcome<T, R>, index: number) => void,
): Promise<Outcome<T, R>[]> {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError('limit must be a positive integer');
}
const results = new Array<Outcome<T, R>>(items.length);
let next = 0;
async function worker(): Promise<void> {
// Each worker takes the next unclaimed index until the list is empty.
while (next < items.length) {
const index = next++;
const item = items[index];
let outcome: Outcome<T, R>;
if (signal.aborted) {
outcome = { item, status: 'rejected', reason: signal.reason };
} else {
try {
outcome = { item, status: 'fulfilled', value: await task(item, signal) };
} catch (reason) {
outcome = { item, status: 'rejected', reason };
}
}
results[index] = outcome;
onSettled?.(outcome, index);
}
}
const count = Math.min(limit, items.length);
await Promise.all(Array.from({ length: count }, () => worker()));
return results;
}
export async function makeThumbnail(
file: Blob,
maxSide: number,
signal: AbortSignal,
): Promise<Blob> {
if (!file.type.startsWith('image/')) {
throw new TypeError(`Unsupported file type: ${file.type || 'unknown'}`);
}
const bitmap = await createImageBitmap(file);
try {
signal.throwIfAborted();
const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d');
if (!context) throw new Error('2D canvas context is not available');
context.drawImage(bitmap, 0, 0, width, height);
return await canvas.convertToBlob({ type: 'image/png' });
} finally {
bitmap.close();
}
}
How it works
runBounded starts min(limit, items.length) workers. JavaScript runs this code on one thread, so next++ cannot be claimed twice: each worker reads and increments the shared index synchronously before it awaits anything. A worker keeps pulling indexes until none remain, which means the queue drains on its own without a separate scheduler. Promise.all waits for every worker, and each worker exits only after its last task has settled.
Errors are converted into outcome objects inside the loop. That is why one corrupt file cannot reject the whole batch. The onSettled callback lets an interface update a progress list as results arrive, while the returned array keeps the input order for the final summary.
Cancellation is cooperative. The queue checks signal.aborted before starting each item, and it passes the same signal to the task. A task that is already running decides how to stop; makeThumbnail checks the signal after decoding, because decoding itself cannot be interrupted. In-flight work may therefore finish normally after an abort.
makeThumbnail scales the longest side down to maxSide and never enlarges a small image, because scale is capped at one. It encodes the result as PNG so a transparent product cutout keeps its alpha channel. If your source files come from a background removal step, for example cutouts exported from RemovePhotoBG, PNG output keeps their transparent background instead of flattening it onto a solid color. bitmap.close() releases decoded pixel memory even when encoding fails.
Using it on a page
Wire the queue to a file input. The limit of three and the 320 value below are example settings; measure your own pages before choosing them.
const input = document.querySelector<HTMLInputElement>('#files');
const controller = new AbortController();
input?.addEventListener('change', async () => {
const files = Array.from(input.files ?? []);
const outcomes = await runBounded(
files,
3,
(file, signal) => makeThumbnail(file, 320, signal),
controller.signal,
(outcome, index) => console.log(index, outcome.status),
);
for (const outcome of outcomes) {
if (outcome.status === 'rejected') {
console.warn(`${outcome.item.name} was skipped`, outcome.reason);
}
}
});
Connect a Cancel button to controller.abort(). Create a new controller for the next batch, because an aborted signal stays aborted.
Testing the behavior
The queue has no browser dependency, so Node 24 can run this test file directly with node queue.test.ts:
import assert from 'node:assert/strict';
import { runBounded } from './queue.ts';
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
let active = 0;
let peak = 0;
const outcomes = await runBounded([1, 2, 3, 4, 5, 6, 7], 3, async (n) => {
active++;
peak = Math.max(peak, active);
await wait(10 * (n % 3));
active--;
return n * 2;
});
assert.equal(peak, 3);
assert.deepEqual(
outcomes.map((o) => (o.status === 'fulfilled' ? o.value : null)),
[2, 4, 6, 8, 10, 12, 14],
);
const mixed = await runBounded(['a.png', 'bad', 'c.png'], 2, async (name) => {
if (name === 'bad') throw new Error('decode failed');
return name.toUpperCase();
});
assert.deepEqual(mixed.map((o) => o.status), ['fulfilled', 'rejected', 'fulfilled']);
const controller = new AbortController();
const started: number[] = [];
const aborted = await runBounded(
[1, 2, 3, 4],
1,
async (n) => {
started.push(n);
if (n === 2) controller.abort(new Error('user cancelled'));
return n;
},
controller.signal,
);
assert.deepEqual(started, [1, 2]);
assert.deepEqual(aborted.map((o) => o.status), ['fulfilled', 'fulfilled', 'rejected', 'rejected']);
await assert.rejects(runBounded([1], 0, async (n) => n), RangeError);
assert.deepEqual(await runBounded([], 4, async (n) => n), []);
console.log('all queue checks passed');
The cancellation case shows the cooperative rule: item 2 triggers the abort while it is running and still completes, while items 3 and 4 never start.
For makeThumbnail, run a short check in the browser console. Draw a semi-transparent rectangle on a 1200 by 800 OffscreenCanvas, convert it to a PNG blob and pass it through the queue together with a plain-text blob and a blob labelled image/png that contains three random bytes. Expect a 320 by 213 PNG whose corner pixel still has partial alpha, a TypeError for the text file, and a decoding error for the broken bytes. Then call the queue with an already aborted signal and confirm that the item is rejected without decoding.
Limits worth knowing
The concurrency limit bounds how many images are being processed at once, not total memory: a single very large photo can still be expensive to decode. If that matters, read dimensions first and reject or route oversized files separately. OffscreenCanvas support differs between browsers, so check the targets you support and fall back to a regular canvas element when needed. Moving makeThumbnail into a Web Worker keeps decoding off the main thread; the queue code works unchanged there.
Top comments (0)