The Test That Changed the Architecture

The worker pool from Lesson 7 is a good first production model: cap active workers at the number of logical CPU cores, queue everything else, and transfer buffers instead of copying them. It prevents the obvious disaster of starting one worker per file.

Then I gave the optimizer a less polite workload:

  • 100 source images
  • 8 responsive widths
  • 4 codecs
  • roughly 3,200 encoded files
  • resizing and upscaling enabled

The application did not crash on desktop. That was the first success. The complete workload initially took roughly 74 minutes; reorganising it into memory-aware phases cut about 15 minutes, bringing the same class of test to approximately 59 minutes.

The more important result was architectural. navigator.hardwareConcurrency told me how much CPU parallelism the browser might expose. It did not tell me whether eight AVIF or JPEG XL workers could coexist with decoded sources, resized targets, previews, ZIP output, and the browser’s own image buffers.

The production question was no longer:

How many workers can this machine run?

It became:

How much memory can one job temporarily require, how many jobs may overlap safely, and what can the browser tell us before it is too late?

This lesson is the answer we arrived at. It is not a replacement for the worker-pool pattern. It is the next layer: turning a CPU-bounded pool into a workload-bounded scheduler.


Compressed Bytes Are the Wrong Unit

A 4.5 MB JPEG sounds small. Its decoded representation may not be.

Every ordinary RGBA pixel needs four bytes:

function decodedRgbaBytes(width: number, height: number): number {
	return width * height * 4
}

decodedRgbaBytes(5712, 4284) // 97,873,536 bytes — about 93.3 MiB

That number describes one raw pixel grid. A real encode can temporarily overlap several allocations:

  1. the compressed source ArrayBuffer
  2. the decoder’s internal memory
  3. the decoded ImageData
  4. a resized or upscaled target
  5. the encoder’s WASM heap
  6. the encoded output buffer
  7. preview or canvas memory controlled by the browser
Loading diagram...

The peak is not necessarily the sum of every box, and different codecs release buffers at different times. But the diagram explains why a per-file limit such as “50 MB maximum upload” is not a memory budget. Encoded size and decoded size are only loosely related.

Output Size is applied too late to protect source decoding

Setting the final output to 1920px does not mean the browser can skip opening the 5712 × 4284 source. In this pipeline the complete source is decoded first, then resized, then encoded. Output dimensions control the delivered file; source admission controls whether processing can start safely.


Workers Isolate Execution, Not the Device Budget

A Web Worker runs in another global context and normally owns a separate JavaScript heap. That is why it keeps WASM encoding off the main thread and preserves interaction. It does not grant the worker a private slice of physical RAM that cannot affect the rest of the page.

All of these may still contribute to the browser’s process-level pressure:

  • main-thread state and retained Blobs
  • one or more worker heaps
  • one WASM linear memory per active codec instance
  • browser-controlled decoded image and canvas storage
  • GPU resources and previews

The browser can terminate a content process when the operating system decides it has crossed a limit. That is fundamentally different from a normal JavaScript exception. There may be no catch, worker error event, or final telemetry sample because the process containing all of them has gone away.

The worker lesson remains correct: move blocking work off the main thread. The production addition is:

Responsiveness and memory safety are separate problems.


Why WASM Cannot Answer “How Much Memory Is Left?”

WebAssembly.Memory exposes the buffer belonging to one module instance. The application can inspect its current byte length, and a module can attempt to grow it in 64 KiB pages.

function wasmBytes(memory: WebAssembly.Memory): number {
	return memory.buffer.byteLength
}

function tryGrow(memory: WebAssembly.Memory, pages: number): boolean {
	try {
		memory.grow(pages)
		return true
	} catch {
		return false
	}
}

That tells us about this WASM memory. It does not reveal:

  • total RAM remaining on an iPhone or iPad
  • the browser’s content-process termination threshold
  • memory currently held by a native JPEG decoder
  • canvas or GPU allocations
  • allocations belonging to another codec worker
  • pressure created by other tabs and applications

The WebAssembly.Memory API is a resizable linear byte buffer, not a system-memory monitor. Similarly, navigator.hardwareConcurrency is a concurrency hint, not a memory measurement.

Chrome exposes navigator.deviceMemory on some platforms, but Safari and Firefox do not provide a dependable equivalent. Even where a coarse RAM tier is available, it describes the device rather than the memory currently available to one content process.

The scheduler therefore treats telemetry as one input, never as permission to allocate until failure.


Replace One Worker Count with a Policy

The teaching pool uses CPU count as its ceiling:

const MAX_CONCURRENCY = navigator.hardwareConcurrency || 4

The production scheduler starts with that ceiling, then applies the strictest limit required by the selected codecs and device-memory tier.

type OutputFormat = 'avif' | 'webp' | 'jpeg' | 'jxl' | 'png'

function computeBatchConcurrency({
	cores,
	deviceMemoryGb,
	formats
}: {
	cores: number
	deviceMemoryGb?: number
	formats: OutputFormat[]
}): number {
	const availableCores = Math.max(1, Math.floor(cores || 1))

	const codecCap = (format: OutputFormat): number => {
		switch (format) {
			case 'webp':
			case 'jpeg':
				return memoryTierCap(deviceMemoryGb, [2, 4, 8, 10], 6)
			case 'png':
				return memoryTierCap(deviceMemoryGb, [2, 3, 5, 6], 4)
			case 'avif':
				return memoryTierCap(deviceMemoryGb, [2, 3, 4, 6], 4)
			case 'jxl':
				return memoryTierCap(deviceMemoryGb, [1, 2, 3, 4], 4)
		}
	}

	return Math.max(1, Math.min(availableCores, ...formats.map(codecCap)))
}

The exact caps are application policy, derived from stress tests rather than a web standard. The general rule is portable:

safe workers = min(CPU ceiling, memory tier, strictest selected codec, workload limit)

For unknown memory, use a conservative fixed cap. A 10-core device with undisclosed memory is not evidence that ten independent WASM codec heaps are safe.

Weight the images too

Worker count alone still treats a 640 × 480 screenshot and a 50 MP camera source as identical jobs. The scheduler assigns larger sources more of the available budget:

function resolutionWeight(width: number, height: number, budget: number): number {
	const pixels = width * height
	if (width <= 0 || height <= 0 || pixels > 50_000_000) return budget
	if (pixels > 30_000_000) return Math.min(3, budget)
	if (pixels > 4_000_000) return Math.min(2, budget)
	return 1
}

function canSchedule(active: number, next: number, budget: number): boolean {
	return active === 0 || active + next <= budget
}

A very large source consumes the full budget and runs alone. Several small sources may overlap. This is still a heuristic, but it models decoded work more accurately than encoded file size or CPU count.


Stage the Work Instead of Treating Every Output Equally

The stress test contained three materially different kinds of variant work:

  1. Standard: target width is at or below the source width
  2. Moderate upscale: target is larger, but remains inside the ordinary safety boundary
  3. Heavy upscale: enlargement factor or output pixel count crosses the sequential boundary

Running them in arbitrary image order lets a few heavy targets occupy memory while hundreds of cheap variants wait. The scheduler now partitions requested widths and runs global phases:

Loading diagram...
function partitionWidths(
	sourceWidth: number,
	sourceHeight: number,
	widths: number[],
	allowUpscale: boolean
) {
	const result = { standard: [] as number[], moderate: [] as number[], heavy: [] as number[] }

	for (const width of widths) {
		if (!allowUpscale || width <= sourceWidth) {
			result.standard.push(width)
			continue
		}

		const height = Math.max(1, Math.round((sourceHeight / sourceWidth) * width))
		const factor = width / sourceWidth
		const pixels = width * height

		if (requiresSequentialLane({ factor, pixels })) result.heavy.push(width)
		else result.moderate.push(width)
	}

	return result
}

This is why phase order matters more than trying to split one image across multiple workers. A decoded source can be reused efficiently inside one worker, while duplicating the same source across workers may multiply the largest allocation in the pipeline.

The fastest phase should finish the largest share of useful work

In the 100-image test, outputs that did not require upscaling were already fast. The improvement came from letting that bulk complete first, then applying tighter concurrency to the smaller moderate queue, and reserving the sequential lane for genuinely heavy targets.


Reuse Workers, but Recycle Them Deliberately

Keeping a worker alive avoids repeating WASM initialisation and source setup. Keeping every worker alive forever can also retain codec memory after the useful work has moved elsewhere.

The compromise is bounded reuse:

  • reuse a warm worker while the queue can consume it immediately
  • do not speculatively prewarm many workers when memory telemetry is absent
  • terminate idle workers above the newly selected codec limit
  • recycle reused workers after a small number of source images
  • terminate every worker when switching application modes
function canReuseWorker({
	queueDepth,
	heapPressure,
	warmWorkers,
	limit
}: {
	queueDepth: number
	heapPressure: boolean
	warmWorkers: number
	limit: number
}): boolean {
	return queueDepth > 0 && !heapPressure && warmWorkers < limit
}

Mode changes are a memory boundary too. Standard, Advanced, and Batch may share components and settings, but they should not silently retain decoded pixels and object URLs from inactive workspaces.


Admit Sources Before You Decode Them

The most reliable memory allocation is the one you never start.

Before a source reaches metadata extraction, a canvas, or a codec worker, the application validates its encoded bytes:

  • file signatures, not extensions or MIME strings, decide the format
  • HEIC and HEIF are rejected when the available decoder cannot support them
  • animated WebP, APNG, and AVIF sequences are rejected before multi-frame decoding
  • detectable Live Photo or motion-photo payloads are rejected as motion sources
  • encoded dimensions are read from a bounded header before pixel decoding

The last point became essential on iOS. A 5712 × 4284 test photo contains roughly 24.5 million pixels. One RGBA grid is already about 93.3 MiB. Attempting to “resize safely” in the browser still requires WebKit to begin decoding the large source, and the content process may be terminated before JavaScript can catch an exception.

The fail-closed policy reads dimensions first and refuses oversized iPhone and iPad sources without decoding pixels:

const IOS_SAFE_SOURCE_PIXELS = 16_000_000

function sourcePlan(width: number, height: number, isIos: boolean) {
	const pixels = width * height
	if (!isIos || pixels <= IOS_SAFE_SOURCE_PIXELS) return { action: 'pass' as const }

	const scale = Math.sqrt(IOS_SAFE_SOURCE_PIXELS / pixels)
	return {
		action: 'reject' as const,
		recommendedDimensions: {
			width: Math.floor(width * scale),
			height: Math.floor(height * scale)
		}
	}
}

For the test photo, the UI recommends approximately 4618 × 3464 pixels or smaller. That is more useful than telling a casual user to “export below 16 MP”, and safer than presenting a button that may crash the tab while trying to fulfil its promise.

A process termination is not a recoverable upload error

If iPadOS terminates the WebContent process during native image decoding, your worker cannot report its WASM memory and your Svelte component cannot render a final toast. The only dependable browser defence is admission work that completes before pixel decoding begins.

The 16 MP boundary is an application safety policy based on tested behaviour. It is not a universal WebKit specification, and a newer iPad may process more. A browser app cannot reliably identify every iPad model or query how much memory remains at that moment, so the public promise must follow the conservative boundary the application can actually defend.


Failure Messages Are Part of the Scheduler

A technically correct rejection can still be a poor product experience.

This message requires the user to understand megapixels:

Export below 16 MP.

This version explains the problem and provides an action:

This photo is too large for safe processing on this device (5712 × 4284px). Resize it to about 4618 × 3464px or smaller, or use a desktop computer.

The message lives in the drop area instead of relying only on a temporary toast. On mobile, browser chrome and safe-area insets can obscure a bottom notification. More importantly, the place where the user chose the file is the place where the corrective action belongs.

A good admission error should answer three questions:

  1. What happened?
  2. Why did the application stop?
  3. What can the user do next?

“Try again” is not a recovery instruction when repeating the operation has the same crash boundary.


Instrument What the Browser Lets You See

No single memory number describes the complete pipeline, but partial telemetry is still valuable when it is labelled honestly.

Track application-owned objects:

  • active and warm workers
  • queue depth
  • retained source File bytes
  • generated Blob bytes
  • live object URLs
  • estimated active decoded bytes (width × height × 4)
  • outputs completed, skipped, and rejected
  • current phase and lane count
  • primary, variant, and total elapsed time
function estimatedDecodedBytes(
	items: Array<{
		status: string
		width: number
		height: number
	}>
): number {
	return items.reduce((total, item) => {
		if (item.status !== 'processing') return total
		return total + item.width * item.height * 4
	}, 0)
}

Call it an estimate. It does not include every native decoder or GPU allocation. Its value is comparison: did a change double the number of active decoded sources, retain completed blobs, or leave warm workers behind after the queue ended?

The Performance panel should reflect the work users are waiting for, not merely the primary image. Separating primary encodes, standard variants, moderate upscale, heavy upscale, skipped outputs, active workers, and phase timers made scheduler bugs visible during the 3,200-output test.


A Verification Matrix for Production Changes

The four-layer testing strategy from Lesson 14 still applies. Memory scheduling adds a few high-value tests at each layer.

Unit tests

  • codec selection uses the strictest concurrency cap
  • unknown memory never unlocks maximum CPU concurrency
  • large sources consume more scheduler weight
  • width partitions are mutually exclusive
  • heavy upscale targets select one sequential lane
  • iPhone, iPad, and desktop-class iPad identities are detected
  • oversized iOS dimensions reject before any decoder is called
  • suggested resize dimensions preserve aspect ratio and stay inside policy

Browser integration tests

  • mode switching terminates workers and revokes URLs
  • rejected uploads leave the drop area usable
  • the error remains visible without depending on a toast
  • skipped variant totals match the per-image summaries
  • download stays disabled until every required phase completes

Stress tests

  • repeat the fixed 100-image × 8-width × 4-codec workload
  • compare total time, per-phase time, peak worker count, and retained blobs
  • repeat with upscaling disabled to isolate codec throughput
  • repeat with one heavy codec and with several codecs
  • test Safari or Firefox separately because memory telemetry differs

Physical-device tests

Desktop emulation cannot reproduce iOS process termination. Test actual iPhone and iPad hardware with:

  • an ordinary still below the source limit
  • a high-resolution still above the limit
  • a Live Photo selection
  • unsupported HEIC/HEIF input
  • repeated uploads after rejection
  • mode switches after successful processing
A passing desktop stress test is necessary, not sufficient

The desktop test proved that the staged scheduler could complete the large workload without crashing. The iOS tests exposed an earlier boundary: source decoding itself. Both results are valid because they measure different layers of the pipeline.


When the Browser Is the Right Tool

Client-side optimization remains compelling when:

  • sources fit inside a defendable admission policy
  • privacy matters and originals should stay local
  • users benefit from immediate previews and codec control
  • the workload can be queued and cancelled visibly
  • the application can fail closed instead of pretending every input is safe

Choose server-side or native processing when:

  • very large sources must always be accepted
  • RAW, HEIC, motion, or specialised formats are core requirements
  • jobs must survive tab closure
  • hardware-independent completion time matters
  • a native decoder can request a downsampled representation without first materialising full pixels

The browser version is not a failed native application. It is a different product boundary: free, private, local, and powerful inside limits that the UI communicates clearly. A future native app can reuse the same scheduling concepts while gaining better control over source decoding and device memory.


Key Takeaways

  • Encoded file size is not a decoded-memory budget. Start with width × height × 4, then account for overlapping decoder, resize, codec, output, and preview allocations.

  • Web Workers solve main-thread responsiveness. They do not create independent physical-memory budgets or protect a browser content process from termination.

  • WASM can inspect and grow its own linear memory. It cannot report WebKit’s native decoder memory, GPU allocations, or remaining device RAM.

  • navigator.hardwareConcurrency is a CPU ceiling, not a safe worker count. Apply codec, memory-tier, resolution, and workload limits, using the strictest result.

  • Partition cheap, moderate, and heavy work into phases. Complete standard variants broadly, reduce concurrency for moderate upscaling, and reserve sequential execution for heavy targets.

  • Reuse warm workers only while queued work can consume them, recycle them deliberately, and terminate inactive workspace workers when users switch modes.

  • Validate signatures, animation, motion payloads, and dimensions before decoding. On iOS, refusing an oversized source from its header may be the only way to guarantee that an error can be displayed.

  • Safety thresholds are application policies backed by tests, not universal browser constants. State that clearly and give users concrete recovery dimensions rather than unexplained megapixel limits.

  • Performance telemetry must cover the complete job: primary work, variant phases, workers, skips, and elapsed time. A progress indicator that ignores variants is not measuring what the user is waiting for.


Further Reading

See Also

Track complete
You've finished Image Optimization.