The Main Thread is for Humans, Not Heavy Math
Drop a high-resolution JPEG onto the optimizer from Lesson 4 and watch what happens during compression. The quality slider becomes unresponsive. The loading indicator stalls mid-animation. Open DevTools, record a Performance trace, and zoom into the main thread: you will find a single long red task on the main thread, the @jsquash WASM encoder, blocking every frame render until it finishes.
This is not a bug in @jsquash. It is how browsers are built. JavaScript runs on a single main thread. That thread handles layout, painting, event dispatch, and script execution. When a WASM codec spends 300 to 800 milliseconds encoding a 4K photo, the browser cannot render a frame, respond to a slider drag, or run any other JavaScript until the codec returns. The user sees a frozen UI.
Web Workers solve this by moving encoding to a separate OS thread. But there is a subtlety that a naive implementation misses: which worker architecture you choose determines whether the quality slider feels instant or sluggish. This lesson builds the production pattern used by tools like Squoosh, and explains why the naive approach fails before fixing it.
Why Long Tasks Freeze the UI
The browser’s rendering pipeline runs on the main thread in a loop. Each iteration picks up one task from the task queue, runs it to completion, then checks whether a frame needs to be rendered. If the frame budget has elapsed (16.7 ms at 60fps), the browser runs layout, paint, and composite before picking the next task.
The critical constraint is that a task runs to completion before anything else can happen. There is no preemption. If WASM encoding takes 600 ms, 36 consecutive frame renders are skipped. The slider drag event sits in the queue, waiting.
WASM encoding is a single blocking call to a codec compiled from C++. Unlike a JavaScript loop that can yield between iterations, a codec call cannot be paused mid-execution. Moving it off the main thread’s task queue entirely is the only fix.
Workers Are Threads Without Shared Memory
A Web Worker runs in its own OS thread with its own JavaScript runtime and its own memory heap. It has full access to WebAssembly, fetch, IndexedDB, and crypto. What it cannot touch is the DOM: document, window, and most browser APIs that affect the page are not available inside a worker.
The isolation is intentional. Without shared memory, two threads cannot corrupt each other’s state. The only communication channel is postMessage: data passes between the main thread and the worker as structured messages, with neither side able to directly read the other’s heap.
For image encoding this model is a perfect fit. The codec takes raw pixel data as input and returns compressed bytes as output. No DOM access is needed at any point.
The Naive Approach and Its Problem
The first instinct when moving encoding to a worker is to create a fresh worker for each compression task. For the batch pool in Lesson 7 that is exactly right; each file is independent and isolation matters. For the single-file optimizer with a quality slider, it produces a sluggish user experience.
Here is why. Each time the user commits a new quality value, a fresh-per-task worker must:
- Initialise the WASM codec (50 to 150 ms on every call)
- Decode the source file into a pixel grid (300 to 600 ms for a 4K JPEG)
- Encode the pixel grid at the new quality (300 to 800 ms)
Steps 1 and 2 are wasted work. The pixels did not change; only the quality setting did. On a mid-range laptop, a quality slider that requires a full re-decode on every commit takes 700 ms to 1.5 seconds per adjustment - slow enough that users stop using it.
The production fix is a stateful long-lived worker that decodes once, caches the pixel grid internally, and only runs step 3 on subsequent quality changes.
This is how Squoosh worksGoogle’s Squoosh uses persistent
WorkerBridgeinstances, one per codec side. The worker stays alive for the entire session. Decoding happens once when a file is dropped; the quality slider only triggers re-encoding against the cached pixel data. The web expert Kaiido, one of the top Canvas and Worker contributors on Stack Overflow, states the principle directly: “DO NOT CREATE ONE SHOT WORKERS. Starting a worker is a heavy operation; keeping it alive is fine in comparison.”
The Two-Phase Message Contract
A stateful worker needs two distinct message types instead of one: a load phase that decodes and caches, and an encode phase that compresses the cached pixels.
// src/lib/workers/image-optimizer.worker.ts
export type OutputFormat = 'webp'
export type InboundMessage =
| { type: 'load'; buffer: ArrayBuffer }
| { type: 'encode'; quality: number; token: number }
export type OutboundMessage =
| { type: 'ready' }
| { type: 'encoded'; buffer: ArrayBuffer; token: number }
| { type: 'error'; message: string; token: number } load carries the raw file bytes and triggers decode. ready confirms the decode completed. encode triggers compression at a given quality. encoded returns the result. The token field lets the main thread discard responses that have been superseded by a newer quality request. Exporting both types gives the compiler visibility into every postMessage call across the codebase.
Building the Stateful Worker
The worker file maintains one piece of state between messages: cachedImageData, the decoded pixel grid.
// src/lib/workers/image-optimizer.worker.ts
/// <reference lib="WebWorker" />
import { decode as decodeJpeg } from '@jsquash/jpeg'
import { decode as decodePng } from '@jsquash/png'
import { decode as decodeWebp, encode as encodeWebp } from '@jsquash/webp'
export type OutputFormat = 'webp'
export type InboundMessage =
| { type: 'load'; buffer: ArrayBuffer }
| { type: 'encode'; quality: number; token: number }
export type OutboundMessage =
| { type: 'ready' }
| { type: 'encoded'; buffer: ArrayBuffer; token: number }
| { type: 'error'; message: string; token: number }
// Narrow self to DedicatedWorkerGlobalScope so ctx.postMessage resolves
// to the worker overload, not Window.postMessage.
const ctx = self as unknown as DedicatedWorkerGlobalScope
// Decoded pixel grid for the current file. Set on load, read on encode.
let cachedImageData: ImageData | null = null
function detectSourceFormat(buffer: ArrayBuffer): 'jpeg' | 'png' | 'webp' {
const view = new Uint8Array(buffer, 0, Math.min(12, buffer.byteLength))
if (view[0] === 0xff && view[1] === 0xd8 && view[2] === 0xff) return 'jpeg'
if (view[0] === 0x89 && view[1] === 0x50 && view[2] === 0x4e && view[3] === 0x47) return 'png'
if (view[8] === 0x57 && view[9] === 0x45 && view[10] === 0x42 && view[11] === 0x50) return 'webp'
throw new Error('Unsupported format. Expected JPEG, PNG, or WebP.')
}
ctx.onmessage = async (event: MessageEvent<InboundMessage>): Promise<void> => {
const msg = event.data
if (msg.type === 'load') {
cachedImageData = null
try {
const format = detectSourceFormat(msg.buffer)
// preserveOrientation applies EXIF Orientation during JPEG decode so
// phone photos align with the browser's auto-rotated <img> preview.
cachedImageData =
format === 'jpeg'
? await decodeJpeg(msg.buffer, { preserveOrientation: true })
: format === 'png'
? await decodePng(msg.buffer)
: await decodeWebp(msg.buffer)
ctx.postMessage({ type: 'ready' } satisfies OutboundMessage)
} catch (err) {
ctx.postMessage({
type: 'error',
message: err instanceof Error ? err.message : 'Decode failed',
token: -1 // -1 signals a load-phase error, not an encode-phase one
} satisfies OutboundMessage)
}
return
}
if (msg.type === 'encode') {
const { quality, token } = msg
if (!cachedImageData) {
ctx.postMessage({
type: 'error',
message: 'No image loaded.',
token
} satisfies OutboundMessage)
return
}
try {
const output = await encodeWebp(cachedImageData, { quality })
// Transfer zero-copy. After this, output.byteLength === 0 on the worker
// side; the main thread owns the allocation.
ctx.postMessage({ type: 'encoded', buffer: output, token } satisfies OutboundMessage, [
output
])
} catch (err) {
ctx.postMessage({
type: 'error',
message: err instanceof Error ? err.message : 'Encode failed',
token
} satisfies OutboundMessage)
}
}
} Three details deserve attention.
The triple-slash directive. /// <reference lib="WebWorker" /> loads lib.webworker.d.ts for this file only. Without it, self is typed as Window and postMessage expects a string target origin as its second argument. Inside a worker, the second argument is a Transferable[]. The mismatch produces Argument of type 'ArrayBuffer[]' is not assignable to parameter of type 'string'. The directive scopes the fix to this file; it does not loosen types elsewhere.
Narrowing self. The double cast self as unknown as DedicatedWorkerGlobalScope resolves the ambiguity when both DOM and WebWorker libs are loaded. Every ctx.postMessage call then compiles against the worker overload.
Token -1 for load errors. The token: -1 on decode errors prevents the main thread from misidentifying a load error as a stale encode response.
WASM initialisation cost
@jsquashloads and compiles its WASM module the first time a decode or encode function is called in a given worker context. In a long-lived worker this cost is paid once per file. In a fresh-per-task worker it is paid on every quality change. This is the primary performance difference between the two patterns.
Transferable Objects: Ownership, Not Copying
By default, postMessage serialises its payload. For a 5 MB ArrayBuffer, that means 5 MB is copied from one heap to the other. Peak memory doubles.
The second argument to postMessage is the Transferable list. Buffers in that list are transferred rather than copied: ownership moves from one thread to the other, and the original becomes detached with byteLength === 0.
// Avoid: buffer is serialised by copy.
worker.postMessage({ type: 'load', buffer })
// Preferred: buffer is transferred zero-copy.
worker.postMessage({ type: 'load', buffer }, [buffer])
// buffer.byteLength === 0 here; the worker owns the allocation. The encoded output from the worker is also transferred back:
// Inside the worker
ctx.postMessage({ type: 'encoded', buffer: output, token } satisfies OutboundMessage, [output])
// output.byteLength === 0 here; the main thread owns it. How Vite Bundles the Worker
Vite’s worker plugin requires the exact pattern new Worker(new URL('...', import.meta.url), { type: 'module' }) with a string literal URL. Any deviation breaks static analysis at build time: the build succeeds but no worker chunk is emitted.
// Preferred: Vite resolves this at build time.
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const worker = new Worker(new URL('./workers/image-optimizer.worker.ts', import.meta.url), {
type: 'module'
}) The { type: 'module' } option is required for TypeScript workers. Without it the worker runs in classic script mode, which does not support ES module imports.
The svelte/prefer-svelte-reactivity lint rule flags new URL(...) as a candidate for SvelteURL. This is a false positive; substituting SvelteURL would silently break the Vite worker plugin. The inline disable comment with a reason is the correct escape hatch.
Updating the Optimizer Module
The optimizer.svelte.ts factory drops all direct @jsquash imports and manages a long-lived worker reference instead. squash() terminates any existing worker, creates a fresh one, and resolves when the first encode completes. setQuality() sends encode messages to the live worker without spawning a new one.
// src/lib/optimizer.svelte.ts (key changes for Lesson 6)
import type { OutboundMessage } from './workers/image-optimizer.worker.ts'
// Monotonic token. Bumped on every squash() and setQuality() so stale
// encode responses can be identified and dropped in onmessage.
let encodeToken = 0
// Persistent worker for the current file. Terminated on new file drop.
let worker: Worker | null = null
async function squash(file: File): Promise<Blob | null> {
if (worker) {
worker.terminate()
worker = null
}
// ... revoke URLs, reset state ...
const currentQuality = quality
status = 'processing'
originalUrl = URL.createObjectURL(file)
return new Promise<Blob | null>((resolve, reject) => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
worker = new Worker(new URL('./workers/image-optimizer.worker.ts', import.meta.url), {
type: 'module'
})
worker.onmessage = (event: MessageEvent<OutboundMessage>) => {
const msg = event.data
if (msg.type === 'ready') {
// Decode done. Send the initial encode.
worker!.postMessage({ type: 'encode', quality: currentQuality, token: ++encodeToken })
return
}
if (msg.type === 'encoded') {
if (msg.token !== encodeToken) return // stale response, drop it
const blob = new Blob([msg.buffer], { type: 'image/webp' })
if (optimizedUrl) URL.revokeObjectURL(optimizedUrl)
optimizedUrl = URL.createObjectURL(blob)
optimizedSize = blob.size
appliedQuality = quality
status = 'done'
resolve(blob) // promise settles on the first encode
return
}
if (msg.type === 'error') {
if (msg.token !== -1 && msg.token !== encodeToken) return
errorMessage = msg.message
status = 'error'
resolve(null)
}
}
worker.onerror = (err) => {
errorMessage = err.message
status = 'error'
reject(err)
}
// Transfer the file buffer zero-copy.
file.arrayBuffer().then((buffer) => {
if (worker) worker.postMessage({ type: 'load', buffer }, [buffer])
})
})
}
function setQuality(value: number): void {
const next = Math.min(100, Math.max(1, Math.round(value)))
if (next === quality || !worker || status === 'idle') return
quality = next
const token = ++encodeToken
status = 'processing'
worker.postMessage({ type: 'encode', quality: next, token })
}
function destroy(): void {
if (worker) {
worker.terminate()
worker = null
}
if (originalUrl) URL.revokeObjectURL(originalUrl)
if (optimizedUrl) URL.revokeObjectURL(optimizedUrl)
} squash() still returns Promise<Blob | null>, so the upload.svelte.ts pipeline from Lesson 5 works without modification. The promise settles on the first successful encode. Every subsequent setQuality() call sends an encode message to the same worker, skipping the decode entirely.
Worker Cleanup on Unmount
The worker holds a live WASM heap. If the user navigates away while a file is loaded, the worker stays alive until the tab closes unless terminated explicitly. Add a $effect cleanup in the page component:
<!-- src/routes/optimizer/+page.svelte -->
<script lang="ts">
import { createUploadPipeline } from '$lib/image/upload.svelte'
const pipeline = createUploadPipeline()
const o = pipeline.opt
$effect(() => {
return () => {
o.destroy() // terminate worker, revoke blob URLs
}
})
</script> The function returned from $effect runs when the component is destroyed. destroy() terminates the worker and revokes both originalUrl and optimizedUrl.
Wiring It Together
This lesson adds one new file and modifies two existing ones. The page template, comparison slider, and metadata panel are unchanged.
src/
lib/
workers/
image-optimizer.worker.ts new this lesson
optimizer.svelte.ts updated: long-lived worker, no @jsquash imports
routes/
optimizer/
+page.svelte $effect cleanup added After this lesson, the same worker file supports two lifecycle patterns:
Long-lived (this lesson): One worker per file. Created on drop, terminated on new file or unmount. Decode once, encode many.
Fresh-per-task (Lesson 7): One worker per batch task. Created, used for one load+encode, terminated. No state persists between files. Isolation and parallelism are the priority.
What Comes Next
The single-file optimizer now offloads encoding to a worker and caches pixels for fast quality changes. The moment a user drops 50 product photos, a single worker becomes the new bottleneck: it processes files serially and the user waits. Lesson 7 solves this with a Worker Pool: a hardware-bounded queue of workers driven by navigator.hardwareConcurrency, where each finished worker immediately dispatches the next file. Same worker file, different lifecycle.
Key Takeaways
The browser event loop runs tasks to completion before rendering. A 600ms WASM encoding call drops 36 frames. Moving it to a worker thread is the only fix.
A fresh-per-task worker for the quality slider forces a full re-decode on every commit (WASM init + decode + encode = 700ms to 1.5s). A long-lived stateful worker skips the first two steps. The difference is the gap between a usable and an unusable slider.
The stateful worker receives
{ type: 'load', buffer }once per file and{ type: 'encode', quality, token }on every quality commit.cachedImageDatalives in the worker between messages; the main thread never holds decoded pixels.The
/// <reference lib="WebWorker" />directive loads worker-scope typings for one file only, without changingtsconfig.json. NarrowselftoDedicatedWorkerGlobalScopeto resolve the type ambiguity.Always transfer
ArrayBufferarguments in the Transferable list. Omitting it copies rather than transfers; a 5 MB image doubles peak memory at the transfer point.The token pattern handles rapid quality changes: bump the token on every
encodedispatch, dropencodedresponses whose token no longer matches.Add a
$effectcleanup that callsdestroy()to terminate the worker and revoke blob URLs when the component unmounts.Vite’s worker plugin requires the literal
new Worker(new URL('...', import.meta.url), { type: 'module' })pattern.SvelteURLbreaks static analysis; use an inline eslint-disable with a reason.The same worker file supports both long-lived (single-file) and fresh-per-task (batch pool) lifecycles. The difference is entirely in how the main thread manages the worker’s lifetime.
Further Reading
- MDN: Web Workers API: full reference for
postMessage, the worker global scope, and supported APIs - MDN: Transferable objects: which types support zero-copy ownership transfer
- MDN: DedicatedWorkerGlobalScope: how the worker global differs from
Window - MDN: Worker.terminate(): exact semantics of immediate shutdown
- MDN: The event loop: how tasks, microtasks, and rendering interleave
- GoogleChromeLabs/squoosh: source reference for the persistent
WorkerBridgepattern - Vite: Web Workers: why
new Worker(new URL(..., import.meta.url))is the only shape Vite recognises - TypeScript: Triple-slash directives:
/// <reference lib="..." />syntax - TypeScript: Discriminated unions: how
typefield narrowing works inonmessage
See Also
- Lesson 4: Your First Client-Side Optimizer - the main-thread version of the optimizer this lesson moves off; the API stays identical, the threading changes underneath.
- Lesson 7: Batch-Processing Dozens of Images in Parallel - the next step: a pool of workers running concurrently, sized to
navigator.hardwareConcurrency. - Lesson 3: @jsquash and WebAssembly - the WASM codecs the worker calls into during the decode/encode cycle.