From One File to Many
So far we have built the /optimizer route: a drop zone, a quality slider, a comparison slider, a download button, a metadata panel that runs concurrently with compression, and a stateful Web Worker that decodes once and re-encodes on every quality change. This lesson adds the next layer: the same drop zone now accepts multiple files, the quality slider re-encodes the active image on demand, and every image you drop gets its own metadata extraction, all without rewriting any of what already works.
The driver is a hardware-bounded Worker Pool. When users drop a folder of 50 product photos, one worker is too slow and 50 workers crash the tab. The pool sits between those extremes, capped at navigator.hardwareConcurrency, with each worker self-scheduling the next item from a queue.
Single vs Batch is a demo separation, not an architectural ruleThis lesson exposes the two flows behind a Single | Batch toggle so you can see exactly which lesson contributes which behaviour. In a real app you do not need the toggle at all; detecting the file count in the drop handler is enough to switch automatically (one file → comparison view, many files → gallery).
The toggle is a teaching aid that makes the contrast explicit; the underlying code does not change. The point of the lesson is to demonstrate incremental evolution of an existing component without forking the demo into parallel routes.
The Two Wrong Answers
When it comes to batch processing images in the browser, two approaches come to mind immediately, and both fail in opposite directions.
The first is a simple serial loop: one file at a time, safe and easy to reason about, but slow enough to make a batch of 50 images feel like waiting for a download.
The second is the opposite impulse: spawn one worker per file and let them all race. It handles ten files fine, but fifty fills browser memory fast enough to crash the tab.
Understanding exactly why each breaks is what makes the Worker Pool feel like the obvious answer rather than an arbitrary design choice.
Serial Processing
The simplest approach to a batch is a loop: compress image one, wait for it to finish, then compress image two.
// Concept - what NOT to do for a batch
async function squashAll(files: File[]): Promise<void> {
for (const file of files) {
await squashOne(file)
}
} This is correct and safe, but devastatingly slow. AVIF encoding of a 4K photo takes 300 to 800 milliseconds on a mid-range laptop. A batch of 50 images processed serially takes up to 40 seconds of wall-clock time, while 7 of the user’s 8 CPU cores sit completely idle.
Spawning a Worker Per File
The natural fix is to process everything at once by creating one worker per file:
// Concept - also wrong
async function squashAll(files: FileList): Promise<void> {
await Promise.all(Array.from(files).map((file) => squashWithWorker(file)))
} With 10 files this works. With 50 files, each carrying a 5 MB ArrayBuffer loaded into memory before being transferred, you are asking the browser to hold 250 MB of raw image data in parallel worker threads. On most devices this terminates the browser tab.
The correct answer is a Worker Pool.
The Mental Model: A Hardware-Bounded Pool
A Worker Pool maintains a fixed number of active workers, determined by the machine’s available CPU cores. When a worker finishes, the pool immediately dispatches the next item from a waiting queue.
Three properties make this pattern correct:
Bounded concurrency. At most MAX_CONCURRENCY workers run simultaneously. The browser never holds more than that many image buffers in memory at once.
Self-scheduling. When any worker finishes, it immediately calls processNext. The pool never stalls waiting for an external signal.
Per-item state. Every dropped file becomes a PreviewItem that owns its own original URL, optimized URL, applied quality, status, and metadata extractor. The gallery reads the items array; the preview reads one item by id. No parallel data structures.
Reading the Hardware
Each Web Worker runs on a real OS thread. When more workers are active than the device has CPU cores, the OS starts time-slicing them and they begin competing for the same cores instead of running in parallel. You pay the memory cost of every extra worker but gain no additional throughput. The pool size therefore needs to match the hardware, and navigator.hardwareConcurrency is how the browser exposes that number.
navigator.hardwareConcurrency returns the number of logical processors available to the browser.
// src/lib/batchOptimizer.svelte.ts (snippet)
const MAX_CONCURRENCY: number = navigator.hardwareConcurrency || 4 The || 4 fallback covers environments where the API is unavailable and the edge case where a system reports 1 core.
Logical vs physical cores
hardwareConcurrencycounts logical processors, including hyperthreaded cores. A CPU that reports 16 logical processors has 8 physical cores. For CPU-bound WASM work, linear gains stop around the physical core count, but saturating all logical processors is still better than leaving any idle.
Never hard-code a number like 4 or 8. A developer’s MacBook Pro and a budget Android phone have wildly different concurrency profiles, and a fixed number is wrong for both.
The PreviewItem Model
The pool’s source of truth is an array of PreviewItems. Each item carries everything the gallery card and the preview drill-down need. A separate completed/failed split would force the page to merge two streams; one array with a status field per item is simpler and survives the preview round-trip cleanly.
// src/lib/batchOptimizer.svelte.ts
import { SvelteSet } from 'svelte/reactivity'
import { createMetadataExtractor, type MetadataExtractor } from './image/metadata.svelte'
import type { OutboundMessage } from './workers/image-optimizer.worker.ts'
export type OutputFormat = 'webp'
export type ItemStatus = 'queued' | 'processing' | 'done' | 'failed'
export interface PreviewItem {
id: string
file: File
name: string
originalUrl: string
originalSize: number
optimizedUrl: string | null
optimizedSize: number | null
optimizedBlob: Blob | null
appliedQuality: number
status: ItemStatus
reason: string | null
/** Per-item reactive metadata extractor; populated lazily on add. */
meta: MetadataExtractor
} Two fields in PreviewItem deserve a note. Each item carries its own MetadataExtractor because MetadataPanel (from lesson 5) binds directly to a single extractor instance; attaching one per item means the same panel component works in the preview drill-down for any image without extra adapter code.
The optimizedBlob is stored alongside optimizedUrl even though <img> only reads the URL, because FormData.append(...) needs the raw blob for a server upload. Discarding it after creating the object URL would mean calling fetch(url).blob() on every upload, re-fetching data you already hold in memory.
The Queue Architecture
The pool is a .svelte.ts factory. Keeping it separate from any component means the gallery state survives navigation between Single and Batch mode (the items don’t reset when the user toggles), and the queue can outlive the component that rendered the upload UI. The .svelte.ts extension is what lets the file use runes ($state, $derived) outside a component.
// src/lib/batchOptimizer.svelte.ts (continued)
interface QueueItem {
itemId: string;
quality: number;
}
interface AddFilesOptions {
quality?: number;
}
const MAX_QUEUE_SIZE = 200;
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
const OUTPUT_FORMAT: OutputFormat = 'webp';
export function createBatchOptimizer() {
const items = $state<PreviewItem[]>([]);
let activeCount = $state(0);
const queue: QueueItem[] = [];
const activeWorkers: SvelteSet<Worker> = new SvelteSet();
let cancelToken = 0;
function findIndex(id: string): number {
for (let i = 0; i < items.length; i++) if (items[i].id === id) return i;
return -1;
}
function processNext(): void {
if (queue.length === 0 || activeCount >= MAX_CONCURRENCY) return;
const next = queue.shift();
if (!next) return;
const idx = findIndex(next.itemId);
if (idx === -1) return; // item was cleared between enqueue and dispatch
const item = items[idx];
const myToken = cancelToken;
activeCount++;
item.status = 'processing';
item.file.arrayBuffer().then((buffer: ArrayBuffer) => {
if (myToken !== cancelToken) return;
const worker = new Worker(
// eslint-disable-next-line svelte/prefer-svelte-reactivity
new URL('./workers/image-optimizer.worker.ts', import.meta.url),
{ type: 'module' }
);
activeWorkers.add(worker);
// Phase 1: transfer buffer for decoding. After this, buffer.byteLength === 0.
worker.postMessage({ type: 'load', buffer }, [buffer]);
worker.onmessage = (event: MessageEvent<OutboundMessage>) => {
activeWorkers.delete(worker);
worker.terminate();
if (myToken !== cancelToken) return;
const liveIdx = findIndex(next.itemId);
if (liveIdx === -1) return;
const live = items[liveIdx];
if (event.data.success) {
// Revoke the previous optimized URL before issuing a new one.
// Re-encodes flow through here repeatedly; without revocation
// each quality tweak would leak a URL allocation.
if (live.optimizedUrl) URL.revokeObjectURL(live.optimizedUrl);
const blob = new Blob([event.data.buffer], { type: `image/${OUTPUT_FORMAT}` });
live.optimizedUrl = URL.createObjectURL(blob);
live.optimizedSize = blob.size;
live.optimizedBlob = blob;
live.appliedQuality = next.quality;
live.status = 'done';
live.reason = null;
} else {
live.status = 'failed';
live.reason = event.data.error;
}
activeCount--;
processNext();
};
worker.onerror = (err: ErrorEvent) => {
activeWorkers.delete(worker);
worker.terminate();
if (myToken !== cancelToken) return;
const liveIdx = findIndex(next.itemId);
if (liveIdx !== -1) {
const live = items[liveIdx];
live.status = 'failed';
live.reason = err.message;
}
activeCount--;
processNext();
};
});
} The fresh-worker-per-task pattern is a deliberate choice for the batch case, and it contrasts with the long-lived worker from the previous lesson. In Lesson 6, the single-file optimizer keeps one worker alive per file - decode happens once and quality changes only trigger a re-encode.
That pattern is wrong for batch processing: each file is independent, and a codec fault in one worker must not affect any other. Fresh-per-task gives that isolation. The 50–150 ms WASM initialisation cost per task is negligible compared to the encoding time, and it means every file starts from a clean state.
The addFiles pushes files into items[] and queues them for encoding. Items that exceed MAX_FILE_SIZE or push the gallery past MAX_QUEUE_SIZE are appended as failed items rather than silently rejected, the gallery card carries the reason so the user sees what was dropped.
// src/lib/batchOptimizer.svelte.ts (addFiles)
function addFiles(files: FileList | File[], options: AddFilesOptions = {}): void {
const { quality = 80 } = options
const incoming = Array.from(files)
const freeSlots = MAX_QUEUE_SIZE - items.length
const accepted = incoming.slice(0, Math.max(0, freeSlots))
const rejectedCount = incoming.length - accepted.length
if (rejectedCount > 0) {
items.push({
id: crypto.randomUUID(),
file: new File([], `(${rejectedCount} files)`),
name: `(${rejectedCount} files)`,
originalUrl: '',
originalSize: 0,
optimizedUrl: null,
optimizedSize: null,
optimizedBlob: null,
appliedQuality: quality,
status: 'failed',
reason: `Gallery full. Limit is ${MAX_QUEUE_SIZE}.`,
meta: createMetadataExtractor()
})
}
for (const file of accepted) {
const id = crypto.randomUUID()
const meta = createMetadataExtractor()
if (file.size > MAX_FILE_SIZE) {
items.push({
id,
file,
name: file.name,
originalUrl: '',
originalSize: file.size,
optimizedUrl: null,
optimizedSize: null,
optimizedBlob: null,
appliedQuality: quality,
status: 'failed',
reason: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024} MB limit.`,
meta
})
continue
}
items.push({
id,
file,
name: file.name,
originalUrl: URL.createObjectURL(file),
originalSize: file.size,
optimizedUrl: null,
optimizedSize: null,
optimizedBlob: null,
appliedQuality: quality,
status: 'queued',
reason: null,
meta
})
// Metadata runs concurrently with compression. A failure in one
// branch does not block the other; the panel just shows null.
void meta.extract(file)
queue.push({ itemId: id, quality })
}
for (let i = 0; i < MAX_CONCURRENCY; i++) processNext()
} The addFiles function does three things in one pass: builds a PreviewItem per accepted file, kicks off metadata extraction concurrently with compression, and queues the file for the worker pool. Metadata is cheap (single-digit milliseconds even for 20 MB files thanks to exifr’s pointer-based parsing), so doing it eagerly costs nothing the user notices.
The next function is the one that makes preview mode interactive.
Per-Item Re-encode
The whole reason for keeping the pool around after the initial encode is so the quality slider in preview mode can drive a re-encode of one image without disturbing the rest of the gallery. reEncodeItem enqueues a single item back through the same pipeline the initial encode used.
// src/lib/batchOptimizer.svelte.ts (continued)
function reEncodeItem(id: string, quality: number): void {
const idx = findIndex(id)
if (idx === -1) return
const item = items[idx]
if (!item.originalUrl) return // unrecoverable failed-on-add item
if (item.status === 'done' && item.appliedQuality === quality) return
item.status = 'queued'
queue.push({ itemId: id, quality })
processNext()
} It is correct for three reasons, not just convenient.
Same code path. reEncodeItem pushes onto the same queue and triggers the same processNext. Concurrency, cancellation, and worker management all behave identically whether the work is the first encode of a file or its tenth quality tweak. There is no special case to maintain.
URL revocation per replacement. The success branch in onmessage revokes the previous optimizedUrl before assigning a new one. Drag the slider four times in preview and each old blob URL is freed before its replacement appears. Without that, every slider change would leak.
No-op for redundant calls. If the user releases the slider at the same value the item is already encoded at, the function returns immediately. Combined with the page’s onchange (commit on release) versus oninput (live display) split, the user can drag the slider freely; only the final position triggers a re-encode.
Why $state Array Mutations Are Tracked
items is declared with $state([]), which wraps the array in a JavaScript Proxy. The Proxy intercepts every property access and method call, so mutations like push, splice, and direct property assignments (item.optimizedUrl = newUrl) are fully tracked without reassignment.
// Concept - Svelte 4 vs Svelte 5 reactivity for collections
// Svelte 4: reassignment required to trigger reactivity
items = [...items, newItem]
// Svelte 5: direct mutation is tracked through the Proxy
items.push(newItem)
// Svelte 5: nested mutations work too - every $state object is proxified
items[0].optimizedUrl = 'blob:...' This is what lets processNext’s worker.onmessage reach in and mutate live.optimizedUrl, live.optimizedSize, live.appliedQuality, etc. directly. The gallery card and the preview component both re-render automatically because every read of item.optimizedUrl in their templates is tracked through the Proxy.
One caveat carries over from earlier lessons: destructuring severs the reactive link.
// Avoid: name and url are captured as plain values at destructure time.
const { name, url } = items[0]
// Preferred: read through the proxy to stay in the reactive graph.
const name = items[0].name
const url = items[0].url Cancelling a Batch in Flight
Real users do not always wait. Cancellation needs three things: drop pending items, terminate active workers, and discard any worker result that arrives after cancellation has been requested. The token-and-set pattern handles all three.
// src/lib/batchOptimizer.svelte.ts (continued)
function cancelAll(): void {
// 1. Bump the token. Any pending arrayBuffer().then() now compares
// unequal and returns without touching activeCount or pushing results.
cancelToken++
// 2. Drop every queued item. queue.length = 0 is the canonical
// in-place clear; reassigning the reference would not be observed
// by closures already holding the old reference.
queue.length = 0
// 3. Terminate every active worker. terminate() is synchronous and
// immediately frees the worker's WASM heap. Once terminated, a
// worker's onmessage will never fire.
for (const worker of activeWorkers) worker.terminate()
activeWorkers.clear()
// 4. Reset the in-flight counter. Pair this with the no-decrement
// branch in the cancelled .then() above; otherwise late file reads
// push activeCount negative and the next batch over-dispatches.
activeCount = 0
// 5. Roll any in-flight items back to a sensible terminal state.
// Items that already had a successful encode keep their result -
// cancellation is about NEW work, not deleting prior outputs.
for (const item of items) {
if (item.status === 'queued' || item.status === 'processing') {
if (item.optimizedUrl) {
item.status = 'done'
} else {
item.status = 'failed'
item.reason = 'Cancelled before first encode finished.'
}
}
}
} The fifth step is new compared to a simpler “results array” pool. Because items survive cancellation (the gallery doesn’t disappear when the user clicks Cancel), each item needs to land in a sensible terminal state.
An item that already had a successful encode stays done even if it was queued for a re-encode at the moment of cancel; its previous optimized URL is still valid. An item that never finished its first encode becomes failed with a clear reason.
The cancellation invariant from the previous lesson’s pool carries over verbatim: the cancelled .then() branch must not decrement activeCount. cancelAll() already resets the counter to 0 in a single step. If the cancelled callback also decremented, the counter would go negative for every read that was mid-flight, and the next batch would dispatch one extra worker per pending read past MAX_CONCURRENCY.
clearAll goes one step further: it cancels in-flight work and also removes every item, revoking both originalUrl and optimizedUrl for each. That is the only path that should ever delete the gallery; cancellation alone preserves it.
// src/lib/batchOptimizer.svelte.ts (continued)
function clearAll(): void {
cancelToken++
queue.length = 0
for (const worker of activeWorkers) worker.terminate()
activeWorkers.clear()
activeCount = 0
for (const item of items) {
if (item.originalUrl) URL.revokeObjectURL(item.originalUrl)
if (item.optimizedUrl) URL.revokeObjectURL(item.optimizedUrl)
}
items.length = 0
} terminate() does not run cleanup code inside the workerCalling
worker.terminate()halts the worker thread immediately. Anyfinallyblocks, file handles, or pendingpostMessageresponses inside the worker are abandoned. For WASM image encoding this is exactly what you want: the codec’s internal state is sandboxed and the new-worker-per-task pattern means no other consumer is sharing it. For workers that hold sockets, IndexedDB transactions, or external resources, you need a softer cancellation that lets the worker tear itself down before terminating.
When to Reach for svelte/reactivity
Svelte 5 ships reactive wrappers for the standard mutable JavaScript classes (SvelteSet, SvelteMap, SvelteDate, SvelteURL, SvelteURLSearchParams) under the svelte/reactivity import.
They look and behave identically to the built-ins but their reads and writes participate in Svelte’s reactivity graph the way $state arrays do. The svelte/prefer-svelte-reactivity lint rule pushes you toward them inside .svelte, .svelte.ts, and .svelte.js files.
The rule is well-intentioned but it does not know what each instance is used for. Two of the three references in this module deserve different decisions.
activeWorkers: SvelteSet is the right call
activeWorkers holds every spawned worker so cancelAll can iterate and terminate them. The UI reads activeCount, a separate $state(0), and never touches the set directly.
If you later want to surface activeWorkers.size as a derived progress detail, that is a one-line change with SvelteSet and a refactor with plain Set. The cost of the wrapper is a single Proxy around at most MAX_CONCURRENCY references: nothing measurable.
The Worker-constructor URL: stick with plain URL
The new URL('./workers/image-optimizer.worker.ts', import.meta.url) argument to new Worker(...) is constructed once, consumed synchronously, and discarded. There is no reactivity story to gain by wrapping it.
More importantly, Vite’s worker plugin keys off this exact AST shape to detect that the module is a worker entry-point and bundle it as a separate chunk. Substituting new SvelteURL(...) silently breaks that static analysis: the build still succeeds, but no worker chunk is emitted and the Worker fails to load at runtime. The lint rule is a false positive in this specific position; an eslint-disable-next-line comment with the reason is the right escape hatch.
A decision rule that generalises
Use svelte/reactivity wrappers when all three are true:
- The instance is stored (not just used inline) and outlives the statement that created it
- Its contents may change over time
- Some reactive consumer (
$derived,$effect, or a template) may eventually need to react to those changes
Reach for the plain built-in when any of those is false: short-lived instances, write-once values, anything consumed by a static-analysis tool that pattern-matches on the constructor name.
Backpressure: When the User Drops 1,000 Files
The pool above caps the number of active workers but not the number of queued items. A 1,000-image folder drop would throw 1,000 File objects into items[]. Each File is cheap on its own, but every entry holds a handle to the underlying disk-backed Blob, and accidentally calling arrayBuffer() on all of them at once would materialise gigabytes of pixel data in main-thread memory before the workers ever see it.
The queue architecture already prevents the second failure mode: only MAX_CONCURRENCY files are ever read into ArrayBuffers concurrently because the read happens inside processNext, which is rate-limited. The first failure mode (gallery length itself) is handled by MAX_QUEUE_SIZE inside addFiles. Files past the cap are appended as failed items with a Gallery full. Limit is 200. reason, so the user sees the truncation as a visible row rather than silent data loss.
// src/lib/batchOptimizer.svelte.ts (snippet - already in addFiles above)
const freeSlots = MAX_QUEUE_SIZE - items.length
const accepted = incoming.slice(0, Math.max(0, freeSlots))
const rejectedCount = incoming.length - accepted.length
if (rejectedCount > 0) {
items.push({
id: crypto.randomUUID(),
// ...
status: 'failed',
reason: `Gallery full. Limit is ${MAX_QUEUE_SIZE}.`
// ...
})
} The same intake pass also rejects oversized files at MAX_FILE_SIZE (50 MB by default). The pool happily transfers a 200 MB file to a worker, where the WASM codec attempts to decode it and immediately runs out of memory. A MAX_FILE_SIZE check before queueing turns a hard crash into a typed failure row the user can see.
The specific numbers depend on the application. A photo-management product accepting RAW exports might want a 100 MB cap; a profile-picture uploader can be aggressive at 5 MB. The principle is the same: every byte that enters items[] is a byte you have committed to holding until either compression or clearAll releases it.
Memory Management for Batch Jobs
Object URLs have non-obvious memory semantics worth understanding explicitly.
When you call URL.createObjectURL(blob), the browser creates an internal mapping from a blob: URL string to the Blob’s underlying memory. That memory is held as long as the URL mapping exists, regardless of whether any JavaScript variable still references the blob. The URL string is the anchor; the memory follows the URL, not the variable.
// Concept - Blob URL memory lifecycle
const url: string = URL.createObjectURL(new Blob(['data']))
// The blob object itself can be garbage collected here, but the underlying
// memory is NOT freed until you explicitly call:
URL.revokeObjectURL(url) A gallery of 50 WebP results averaging 150 KB each is 7.5 MB held by object URLs alone. Add the 50 originals (5 MB each on average) and you’re sitting on 257 MB of held memory. The pool revokes URLs in three places, and missing any one of them leaks:
- Re-encode replaces an optimizedUrl.
worker.onmessagerevokes the previous URL before assigning the new blob’s URL. Every quality tweak in preview mode goes through this path. - clearAll empties the gallery. Every item’s
originalUrlandoptimizedUrlare revoked before the array is truncated. - The page unloads. Browsers revoke remaining blob URLs on page unload; this is a backstop, not a primary strategy. Relying on it means peak memory in a long session keeps climbing.
For the upload path specifically, hold the blob (not the URL) as the source of truth. Each PreviewItem has both, but the optimizedBlob is what FormData.append(...) consumes, and uploading directly from the blob is one fetch lighter than re-fetching the URL.
// src/routes/optimizer/+page.svelte (script section, conceptual)
import type { PreviewItem } from '$lib/batchOptimizer.svelte'
async function uploadAll(items: PreviewItem[]): Promise<void> {
for (const item of items) {
if (!item.optimizedBlob) continue
const formData = new FormData()
formData.append('file', item.optimizedBlob, item.name)
await fetch('/api/upload', { method: 'POST', body: formData })
}
} Extending the Optimizer Route
This is the heart of the lesson: how the existing /optimizer page from Lessons 4–5 grows three small additions to support batches. There are exactly three changes: extend DropZone with an additive callback, add the batch pool next to the existing pipeline, and conditionally render the gallery or the preview drill-down. Lesson 4–5’s single-file flow is preserved completely.
1. Teach DropZone to forward every file
The drop zone in Lesson 4 forwards a single File because that is all the comparison view consumes. Adding an optional onfiles callback alongside the existing onfile keeps every existing caller working while letting the batch path receive the whole list.
<!-- src/lib/components/DropZone.svelte (props and intake handler) -->
<script lang="ts">
interface Props {
onfile?: (file: File) => void
onfiles?: (files: File[]) => void
multiple?: boolean
}
let { onfile, onfiles, multiple }: Props = $props()
// Multi-select on the file picker is opt-in. It defaults to true when
// onfiles is provided so the consumer doesn't need to set both props.
const allowMultiple = $derived(multiple ?? Boolean(onfiles))
function emit(files: FileList | File[] | null | undefined): void {
if (!files || files.length === 0) return
const arr = Array.from(files)
if (onfiles) onfiles(arr)
else if (onfile) onfile(arr[0])
}
</script>
<input
id="file-input"
type="file"
multiple={allowMultiple}
accept="image/jpeg,image/png,image/webp"
class="sr-only"
onchange={(e) => emit((e.currentTarget as HTMLInputElement).files)}
/> The drag-and-drop handlers from Lesson 4 already exist on the wrapper element. The only change inside the existing drop handler is calling emit(event.dataTransfer?.files) instead of forwarding files[0]. That one-line change makes drag-and-drop deliver the entire FileList regardless of how the underlying input is configured.
The most common batch demo bugIf your drop zone is a
<label>styled to look like a target, you must callevent.preventDefault()ondragoverANDdrop. Without those two calls, the browser’s default action on a drop is “navigate to this file”, and the tab opens the first dropped file.Users perceive that as “only one file was processed” even though the pool never ran. The handlers above live on the wrapper element, not the input, so the input’s
multipleattribute is irrelevant to drag-and-drop; only the handlers’preventDefaultmatters.
2. Add the batch pool alongside the existing pipeline
The optimizer page keeps every Lesson 4–5 import and adds the batch factory next to them. Both factories live in the page at the same time. A Mode toggle in the UI switches between them.
<!-- src/routes/optimizer/+page.svelte (script section) -->
<script lang="ts">
import { createUploadPipeline } from '$lib/image/upload.svelte'
import { createBatchOptimizer } from '$lib/batchOptimizer.svelte'
import MetadataPanel from '$lib/image/MetadataPanel.svelte'
import DropZone from '$lib/components/DropZone.svelte'
import ComparisonSlider from '$lib/components/ComparisonSlider.svelte'
type Mode = 'single' | 'batch'
let mode = $state<Mode>('single')
// Lessons 4-5: untouched.
const pipeline = createUploadPipeline()
const o = pipeline.opt
const meta = pipeline.meta
// This lesson: the preview-aware pool.
const batch = createBatchOptimizer()
// Drill-down selector for batch mode.
let previewId = $state<string | null>(null)
const previewItem = $derived(
previewId ? (batch.items.find((it) => it.id === previewId) ?? null) : null
)
// One reactive quality value drives both modes.
let liveQuality = $state<number>(o.quality)
$effect(() => {
if (mode === 'single') liveQuality = o.quality
else if (previewItem) liveQuality = previewItem.appliedQuality
})
async function handleFiles(files: File[]): Promise<void> {
if (mode === 'single') {
// Lesson 4-5 path: only the first file enters the comparison
// view. Extras are ignored on purpose so the UX stays focused.
await pipeline.process(files[0])
return
}
batch.addFiles(files, { quality: liveQuality })
}
function commitQuality(value: number): void {
const clamped = Math.min(100, Math.max(1, Math.round(value)))
liveQuality = clamped
if (mode === 'single') {
void o.setQuality(clamped)
return
}
if (previewItem) {
batch.reEncodeItem(previewItem.id, clamped)
}
}
</script> Three things make this design hold together.
liveQualityis the only quality state the page owns; the$effectkeeps it synchronized with whichever data source is currently active (single optimizer vs current preview item). `commitQualityruns on the slider’sonchange(release), notoninput(live), so dragging the slider does not fire 100 re-encodes.handleFilesbranches onmode, not onfiles.length: the toggle controls which pipeline handles the drop, even if the user happens to drop one file in batch mode.
3. Render gallery or preview, never both
The template stays mostly Lesson 4–5; the <DropZone>, the quality slider, the comparison view, and the metadata panel are all in the same positions. Two new conditional blocks handle the batch-mode views.
<!-- src/routes/optimizer/+page.svelte (template snippet) -->
<DropZone
multiple={mode === 'batch'}
onfiles={(files: File[]) => void handleFiles(files)}
/>
<!-- Single mode (Lessons 4-5): unchanged -->
{#if mode === 'single' && o.status === 'done' && o.originalUrl && o.optimizedUrl}
<section class="optimizer__results">
<ComparisonSlider
before={o.originalUrl}
after={o.optimizedUrl}
originalSize={o.originalSize}
optimizedSize={o.optimizedSize}
appliedQuality={o.appliedQuality}
/>
<MetadataPanel {meta} />
</section>
{/if}
<!-- Batch preview drill-down -->
{#if mode === 'batch' && previewItem}
<section class="optimizer__preview" aria-label="Image preview">
<button type="button" onclick={() => (previewId = null)}>← Back to gallery</button>
{#if previewItem.optimizedUrl}
<ComparisonSlider
before={previewItem.originalUrl}
after={previewItem.optimizedUrl}
originalSize={previewItem.originalSize}
optimizedSize={previewItem.optimizedSize ?? 0}
appliedQuality={previewItem.appliedQuality}
/>
{/if}
<MetadataPanel meta={previewItem.meta} />
</section>
{/if}
<!-- Batch gallery -->
{#if mode === 'batch' && !previewItem && batch.items.length > 0}
<section class="optimizer__batch">
<header>
<p>
{#if batch.isProcessing}
{batch.activeCount} workers running
{:else}
{batch.doneCount} of {batch.items.length} done
{/if}
</p>
{#if batch.isProcessing}
<button type="button" onclick={batch.cancelAll}>Cancel</button>
{/if}
<button type="button" onclick={batch.clearAll}>Clear gallery</button>
</header>
<div class="grid">
{#each batch.items as item (item.id)}
<button type="button" onclick={() => (previewId = item.id)}>
<img src={item.optimizedUrl ?? item.originalUrl} alt={item.name} loading="lazy" />
<footer>
<span>{item.name}</span>
{#if item.status === 'done' && item.optimizedSize !== null}
<strong>
{Math.round((1 - item.optimizedSize / item.originalSize) * 100)}% smaller
· q={item.appliedQuality}
</strong>
{:else if item.status === 'failed'}
<span class="failed">{item.reason ?? 'Failed'}</span>
{:else}
<span>{item.status}…</span>
{/if}
</footer>
</button>
{/each}
</div>
</section>
{/if} Three small touches in this template are worth calling out.
- The
{#each}keys onitem.id, neveritem.name, because file names are not unique (twoIMG_0001.jpgfiles from the same device are common) and a colliding key breaks Svelte’s reconciliation. - The card is a
<button>, so keyboard navigation works without extra ARIA. MetadataPanel meta={previewItem.meta}is a single-line drop-in: the panel was already a reactive component bound to aMetadataExtractor; per-item extractors mean it just works in preview mode without a per-item adapter.
In a real app, you would not need a Mode toggleThe Single | Batch toggle is a teaching aid. In production you would detect the file count in the drop handler and switch automatically: one file → comparison + metadata, many files → gallery. The underlying data shapes don’t change.
The toggle exists in this lesson because the article needs to make the contrast between Lessons 4–5 (single flow) and Lesson 7 (batch flow) explicit. Once you understand both paths, collapsing them into one auto-switching handler is a five-line change.
Common Mistakes and Anti-Patterns
Spawning the worker at module init time
// Anti-pattern (would live in src/lib/batchOptimizer.svelte.ts)
// Avoid: the worker and its WASM codec load the moment the module is imported.
const worker = new Worker(new URL('./workers/image-optimizer.worker.ts', import.meta.url), {
type: 'module'
})
export function processFile(file: File): void {
worker.postMessage(file)
} A module-level worker initialises its WASM codec immediately on import, whether or not the user ever uploads a file. A singleton worker that crashes or enters a bad WASM state takes all future processing with it. Fresh-per-task workers restart from a clean state on every dispatch.
Skipping the onerror handler
// Anti-pattern: a worker error never reaches onmessage, and the pool hangs.
worker.onmessage = (event) => {
activeCount--
worker.terminate()
processNext()
} Without onerror, activeCount is never decremented for a failing slot and the pool eventually stalls at maximum concurrency. Always handle both outcomes; both must terminate the worker, decrement the counter, and call processNext.
Not transferring the ArrayBuffer
// Avoid: serialises the buffer by copy (5 MB image = 5 MB memcopy).
worker.postMessage({ buffer, format, quality })
// Preferred: transfers ownership, zero-copy.
worker.postMessage({ buffer, format, quality }, [buffer]) The second argument to postMessage is the Transferable list. Omitting it doubles peak memory for every dispatch. After a transfer, buffer.byteLength === 0 on the sending side; the worker now owns the original allocation.
Hard-coding the concurrency limit
// Wrong on almost every device.
const MAX_CONCURRENCY = 4
// Preferred: read from the hardware.
const MAX_CONCURRENCY = navigator.hardwareConcurrency || 4 4 is too many for a two-core budget phone and too few for a 16-core workstation.
When Not to Use a Worker Pool
The Worker Pool pattern is the right tool when the work is CPU-bound, benefits from parallelisation, and runs in the browser. There are cases where those conditions do not hold.
Server-side SvelteKit routes. Node.js has worker_threads, but it is a different API. Code using new Worker(new URL(...), { type: 'module' }) throws in a server context. For server-side image processing, use Sharp instead.
Already-optimised inputs. PNG screenshots at 50 KB or aggressively compressed WebP files barely benefit from re-encoding, and the WASM startup cost outweighs the savings.
Small batches under five files. Worker startup and WASM initialisation cost 50–150 ms per worker. For one or two files, that overhead is negligible. For three files, spawning three workers can add more latency than processing them serially through a single reused worker. Profile before committing to the pool for small-batch flows.
Pipelines where the server re-encodes everything. If your backend uses Cloudflare Images, Imgix, or AWS Lambda to generate variants, the client’s job is to send the file efficiently and let the server produce the outputs.
Wiring It Together
This lesson adds two new files, modifies two existing ones, and creates no new routes. The worker file from the previous lesson stays exactly as it was.
src/
lib/
batchOptimizer.svelte.ts new this lesson
workers/
image-optimizer.worker.ts same file as previous lesson; fresh-per-task lifecycle here
components/
DropZone.svelte extended: optional onfiles + multiple
routes/
optimizer/
+page.svelte extended: Mode toggle, gallery, preview The same /optimizer page that Lessons 4–5 built is the page you use here. Switch the toggle to Single and the page is exactly Lessons 4–5. Switch to Batch and the same drop zone now feeds a per-item gallery; clicking a card uses the same comparison slider and metadata panel against that item’s own state.
Typing the worker file with /// reference lib=WebWorkerThe worker file from the previous lesson already uses this pattern. Here is a reminder of why the directive is necessary:
// src/lib/workers/image-optimizer.worker.ts /// <reference lib="WebWorker" /> import { decode as decodeJpeg } from '@jsquash/jpeg' // ...That single comment is a TypeScript triple-slash directive. It tells the compiler to load
lib.webworker.d.tsfor this file only. Without it, the worker file inherits the project’s default DOM typings, whereselfisWindowandpostMessage(message, targetOrigin: string, transfer?: Transferable[])expects a string second argument.Inside a real Worker context the global is
DedicatedWorkerGlobalScopeandpostMessage(message, transfer?: Transferable[])expects aTransferable[]second argument. Mismatching those produces a confusingArgument of type 'ArrayBuffer[]' is not assignable to parameter of type 'string'error on the very line we care about most.The directive is preferred over editing
tsconfig.jsonto add"WebWorker"tolibbecause a project-wide change loosens typings for every component and route. Per-file scoping is the canonical pattern for projects that mix DOM and worker code under a single tsconfig. With both libs visible, narrowselfonce at the top:const ctx = self as unknown as DedicatedWorkerGlobalScope, then usectx.postMessagefrom there.
Measuring the Pool
Before committing the pattern to production, instrument it once and confirm the behaviour matches the model. Three measurements are enough.
Wall-clock for a fixed batch. Compress the same 50 images on a laptop with MAX_CONCURRENCY = 1 and again with MAX_CONCURRENCY = navigator.hardwareConcurrency. The ratio is the actual speed-up your users will see, usually between 4× and 7× on an 8-physical-core machine. Below 2×, the bottleneck is not the pool; profile arrayBuffer() reads or codec init time.
Peak heap during the batch. Open Chrome DevTools, switch to Memory, take a heap snapshot before the batch and another at the moment the progress bar is around 50%. The midpoint snapshot should be roughly MAX_CONCURRENCY × average decoded image size, plus a few megabytes of WASM overhead per worker. Far above that points at a leak in cancellation or revocation.
Frame rate during processing. The Performance tab’s frame meter should stay at 60 fps. Workers are off the main thread, so the only thing the page itself does is render the gallery. Drops below 60 fps usually mean the progress bar is being recomputed too often, or the {#each} block over batch.items is not keyed correctly and Svelte is re-rendering more cards than necessary.
These three numbers turn the pool from a pattern you trust into a pattern you have measured, and they are the right baseline to revisit when adding new codecs or quality settings.
What Comes Next
The pipeline now batches, cancels, re-encodes per item, and surfaces compressed images with their metadata in real time on the same /optimizer route. The compressed bytes still leave the application as raw blobs, though, and the next two lessons turn those blobs into HTML the browser can render efficiently.
Lesson 8 introduces the <picture> element, srcset, and sizes, which together solve the dimension and responsive-sizing problems Lesson 1 identified as the single biggest source of image waste.
Lesson 9 builds the LazyImage component that defers off-screen downloads using the loading="lazy" attribute and IntersectionObserver. Both lessons assume an optimised blob coming out of either flow, so any pre-existing storage or upload step can stay unchanged.
For the upload side specifically, the optimizedBlob and name properties on each item are everything a FormData-driven POST needs. Combined with the metadata extracted in Lesson 5, the database row and the storage object are written from the same client-side commit.
Key Takeaways
The Single | Batch separation in this lesson is a teaching aid, not an architectural rule. In production you switch on file count inside the drop handler: one file goes to the comparison view, many files go to the gallery, and the rest of the code is identical.
The pool’s source of truth is one array of
PreviewItems. Each item carries its own original URL, optimized URL, applied quality, status, and reactive metadata extractor. The gallery iterates items; the preview reads one item by id; both views share the same state.reEncodeItem(id, quality)is the lever that makes the quality slider feel native in preview mode. It enqueues a single item through the same worker pool the initial encode used; concurrency, cancellation, and worker management all behave identically.URL.createObjectURL()allocations leak unless every replacement and every gallery clear revokes the predecessor. The pool revokes on re-encode, on cancel-with-clear, and onclearAll. Skipping any of those leaks proportional to how many quality tweaks the user makes.navigator.hardwareConcurrencyprovides the concurrency ceiling. The|| 4fallback covers environments where the API is unavailable. Hard-coding 4 or 8 is wrong on almost every device.Cancellation needs four moves: bump a token, clear the queue, terminate active workers, reset the in-flight counter. The cancelled
.then()branch must NOT decrement the counter, becausecancelAllalready accounts for every in-flight task in one step.svelte/reactivitywrappers (SvelteSet,SvelteMap,SvelteURL, etc.) are the right choice when a mutable instance is stored, mutated over time, and read by reactive consumers. They are wrong when the instance is consumed inline by static-analysis-driven tooling; the literalnew URL(...)insidenew Worker(...)is the canonical example, because Vite’s worker plugin pattern-matches that AST shape.A TypeScript worker file needs
/// <reference lib="WebWorker" />at the top to load worker-scope typings. Per-file scoping keeps DOM-only typings strict everywhere else.Cap queue size and per-file size at intake. Failures land in the gallery as visible cards with reasons, not as silent drops.
Measure before optimising. Wall-clock speed-up, mid-batch heap snapshot, and frame rate during processing are the three numbers that confirm the pool is doing what the model promises.
Further Reading
- MDN: Web Workers API: full reference for
postMessage, Transferable Objects, andterminate() - MDN: navigator.hardwareConcurrency: browser support and specification details
- MDN: URL.createObjectURL(): memory lifecycle for Blob URLs
- MDN: Worker.terminate(): exact semantics of immediate worker shutdown
- MDN: Transferable objects: which types can move zero-copy across the postMessage boundary
- MDN: DedicatedWorkerGlobalScope: the global scope inside a Web Worker
- Svelte 5: $state: deep reactivity, Proxy mechanics, and
$state.raw - Svelte 5: $derived: dependency tracking semantics
- Svelte 5: TypeScript with runes: using
$state<T>()generics, typed getters in factory returns, and the.svelte.tsextension - Svelte 5: svelte/reactivity: reactive
Set,Map,Date,URL, andURLSearchParamswrappers - TypeScript: Discriminated unions: the narrowing pattern behind the typed message contract
- TypeScript: Triple-slash directives:
/// <reference lib="..." />syntax and the full list of standard libs - Vite: Web Workers: why
new Worker(new URL(..., import.meta.url))is the only worker-import shape Vite recognises
See Also
- Lesson 6: Keeping Your UI at 60fps - the single-worker foundation this pool builds on; the message contract here is exactly the one defined there.
- Lesson 8: Stop Sending 4000px Images to a 400px Phone - the next lesson, adding
encode-variantsto the same pool to produce responsive widths. - Lesson 10: Persisting the Pipeline - where the encoded buffers this pool produces are uploaded; presigned PUTs and the database commit.
- Lesson 14: Testing the Pipeline - unit-tests for the worker pool’s message contract, cancellation token logic, and bounded concurrency. The pool is the highest-value test target in the track.
- Lesson 15: The Proof of Work - Lighthouse Audits - the audit that proves the worker pool actually moves Total Blocking Time and frame rate during batch processing.