A Non-Blocking Image Editor with Nuxt 4 + TypeScript + Web Workers + Comlink + OffscreenCanvas

nuxt-4-web-workers-comlink-offscreencanvas

Drag an exposure slider over a 24-megapixel photo and you get one of two experiences. Either the image updates as your finger moves, or the slider itself stops moving — sticking to your cursor, jumping, arriving somewhere you didn't aim for. The second one isn't a slow-code problem. It's a location problem: the pixel loop and the slider are running on the same thread, and a loop over 96 million bytes does not fit inside 16 milliseconds.

You can micro-optimise that loop for a week and it will still be too slow, because the fix isn't making the work smaller. It's moving the work somewhere the UI isn't.

What makes this worth building rather than reading about: once the canvas itself lives in the worker, the main thread has nothing left to block on. It doesn't paint. It doesn't read pixels. It receives a slider event, forwards five numbers, and goes back to being idle. The picture updates from a thread your user can't stall.

Tags: Nuxt 4, Web Workers, Comlink, OffscreenCanvas, Canvas API, TypeScript

Time to read: 17 min

What you'll build: a darkroom. Drop in a photo, push exposure, contrast, gamma, saturation and vignette around with live preview, then export the adjustment at full resolution with a progress bar. Every pixel touched in a worker; the main thread never sees an ImageData.

Why this combination

Four pieces, and each is here because the others can't do its job.

Web Workers give you a second thread with its own event loop. That's the whole point — but their native interface is postMessage and a switch statement on event.data.type, which is how worker code ends up untyped and unpleasant.

Comlink fixes exactly that. It's 1.1kB that turns the message channel into an RPC proxy, so calling into the worker looks like await api.setAdjustments(next). Crucially for us it's typed: expose a T, wrap it, get a Remote<T>. The compiler checks the boundary that postMessage erases.

OffscreenCanvas is what makes this a real solution rather than a partial one. Without it, the worker does the maths and posts pixels back, and the main thread still pays for the final paint on every frame. With transferControlToOffscreen(), ownership of the <canvas> moves to the worker, and the main thread is out of the render path entirely.

Nuxt 4 + Vite supply the build. new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' }) is understood by Vite in both dev and build, so the worker is a real TypeScript module in your repo rather than a string of code or a separate build step. The Nuxt-specific work is a single guard — workers don't exist on the server.

One clarification before we start, because the names collide: a Web Worker is a background thread for computation. A Service Worker is a network proxy for offline and caching, which is a different tool for a different job — that one shows up in the offline PWA build. Nothing here involves a service worker.

Prerequisites

  • Node.js 20+
  • Basic Nuxt 4 / Vue 3 Composition API
  • A rough idea of what ImageData is — four bytes per pixel, RGBA, row-major
  • A large photo to test with. Small images make everything look fine, which is the problem

1) Scaffold and install

bash
npx nuxi@latest init nuxt4-darkroom
cd nuxt4-darkroom
npm i comlink

That's the entire runtime dependency list. comlink resolves to 4.4.x, and everything else is a browser API. Section 6 uses useResizeObserver from @vueuse/nuxt, which most Nuxt projects already have — a plain ResizeObserver does the same job if yours doesn't.

One line of Vite config is worth adding up front:

ts
// nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: '2025-07-15',

  vite: {
    worker: {
      // Build defaults to 'iife', which cannot code-split. Dev serves a
      // real module worker because we pass { type: 'module' }. Match them.
      format: 'es',
    },
  },
})

Vite's worker.format defaults to 'iife', and the default is fine right up until the worker bundle needs more than one chunk — at which point the build either fails outright (IIFE output cannot code-split) or quietly inlines what you expected to be a lazy import. Dev, meanwhile, serves a native module worker because of the { type: 'module' } argument you'll pass to the constructor. Setting format: 'es' makes the two agree. Module workers need { type: 'module' } support in the browser, which every current engine has.

2) The contract, in shared/

The worker and the component both need to agree on what an adjustment is. Nuxt 4's shared/ folder is the place both sides can import from:

ts
// shared/image.ts

/** Everything the user can push around. Plain numbers — structured cloneable. */
export interface Adjustments {
  /** Stops of exposure. 0 is unchanged, +1 doubles the light. */
  exposure: number
  /** -100..100, where 0 is unchanged. */
  contrast: number
  /** 0.5..2.2. Below 1 lifts shadows. */
  gamma: number
  /** 0 is greyscale, 1 unchanged, 2 lurid. */
  saturation: number
  /** 0..1 corner darkening. */
  vignette: number
}

export const NEUTRAL: Adjustments = {
  exposure: 0,
  contrast: 0,
  gamma: 1,
  saturation: 1,
  vignette: 0,
}

export interface ImageInfo {
  width: number
  height: number
}

export type ExportType = 'image/png' | 'image/jpeg' | 'image/webp'

Adjustments is deliberately five numbers and nothing else. Everything crossing a worker boundary goes through the structured clone algorithm, which handles plain data beautifully and refuses functions, DOM nodes, class prototypes and anything with a getter. Keeping the message shape boring is not a style choice — it's the constraint.

A note that will save you an hour: Nuxt's auto-imports do not reach worker files. A worker is compiled as its own entry, outside the app's transform pipeline, so ref, useState and your ~/composables are not magically available in there. Import everything explicitly. This is also why the contract lives in shared/ rather than in a composable.

3) The pixel work

This is the code that has no business being on the main thread. Put it in its own module so it stays testable — it's pure functions over typed arrays, with no worker API in sight:

ts
// app/workers/pipeline.ts
import type { Adjustments } from '#shared/image'

/**
 * A 256-entry lookup table folding exposure, contrast and gamma into one
 * array index. Built once per adjustment change, then read 3x per pixel.
 */
export function buildLut({ exposure, contrast, gamma }: Adjustments) {
  const lut = new Uint8ClampedArray(256)
  const gain = 2 ** exposure
  const c = (contrast + 100) / 100
  const invGamma = 1 / gamma

  for (let i = 0; i < 256; i++) {
    let v = (i / 255) * gain
    v = (v - 0.5) * c + 0.5
    v = v <= 0 ? 0 : v ** invGamma
    lut[i] = v * 255
  }
  return lut
}

/**
 * Per-pixel vignette falloff. Depends only on dimensions and amount,
 * so it survives every slider move that isn't the vignette slider.
 */
export function buildVignette(width: number, height: number, amount: number) {
  const mask = new Float32Array(width * height)
  const cx = width / 2
  const cy = height / 2
  const maxR = Math.hypot(cx, cy)

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const d = Math.hypot(x - cx, y - cy) / maxR
      mask[y * width + x] = 1 - amount * d * d
    }
  }
  return mask
}

/** Rec. 709 luma — the weights that make desaturation look right. */
const LR = 0.2126
const LG = 0.7152
const LB = 0.0722

export function applyRange(
  src: Uint8ClampedArray,
  dst: Uint8ClampedArray,
  lut: Uint8ClampedArray,
  mask: Float32Array | null,
  saturation: number,
  startPixel: number,
  endPixel: number,
) {
  for (let p = startPixel; p < endPixel; p++) {
    const i = p << 2

    let r = lut[src[i]!]!
    let g = lut[src[i + 1]!]!
    let b = lut[src[i + 2]!]!

    if (saturation !== 1) {
      const lum = LR * r + LG * g + LB * b
      r = lum + (r - lum) * saturation
      g = lum + (g - lum) * saturation
      b = lum + (b - lum) * saturation
    }

    if (mask) {
      const v = mask[p]!
      r *= v
      g *= v
      b *= v
    }

    dst[i] = r
    dst[i + 1] = g
    dst[i + 2] = b
    dst[i + 3] = src[i + 3]!
  }
}

Four decisions in there are the difference between this being fast and being merely off-thread.

The LUT collapses three operations into one array read. Exposure, contrast and gamma are all functions of a single channel value, so there are only 256 possible answers. Computing v ** invGamma per pixel means ~72 million Math.pow calls on a 24MP image; computing it 256 times and indexing is free by comparison.

The vignette mask is cached separately from the LUT. It costs a Math.hypot call per pixel to build, which is the most expensive thing here — and it doesn't change when you move the exposure slider. Splitting the two caches means the common case rebuilds 256 entries instead of a million floats.

applyRange takes a pixel range rather than the whole buffer. That's not needed for the preview, which runs in a few milliseconds. It's what lets the full-resolution export report progress and stay interruptible, in section 9.

And src and dst are separate. Reading and writing the same buffer works for this particular pipeline, but the moment you add anything that samples neighbouring pixels — blur, sharpen, any convolution — in-place mutation reads pixels you already modified and the output smears in the direction of your loop. Two buffers now costs one allocation and saves that bug forever.

Now the stateful part. The worker owns the source image, the canvas, and the caches:

ts
// app/workers/darkroom.worker.ts
import * as Comlink from 'comlink'
import type { Adjustments, ExportType, ImageInfo } from '#shared/image'
import { NEUTRAL } from '#shared/image'
import { applyRange, buildLut, buildVignette } from './pipeline'
// ExportType is used by exportImage, added to this same object in section 9.

let source: ImageBitmap | null = null
let canvas: OffscreenCanvas | null = null
let ctx: OffscreenCanvasRenderingContext2D | null = null

let base: ImageData | null = null      // the preview, unmodified
let out: ImageData | null = null       // reusable output buffer
let adjustments: Adjustments = { ...NEUTRAL }

let lut = buildLut(NEUTRAL)
let mask: Float32Array | null = null
let maskKey = ''

/** rAF inside a dedicated worker is Chromium-only. Most users take the fallback. */
const schedule: (cb: () => void) => void
  = 'requestAnimationFrame' in self
    ? cb => void self.requestAnimationFrame(() => cb())
    : cb => void setTimeout(cb, 16)

let pending = false
function invalidate() {
  if (pending) return
  pending = true
  schedule(() => {
    pending = false
    render()
  })
}

function render() {
  if (!ctx || !base || !out) return

  const { width, height } = base

  if (adjustments.vignette > 0) {
    const key = `${width}x${height}:${adjustments.vignette}`
    if (key !== maskKey) {
      mask = buildVignette(width, height, adjustments.vignette)
      maskKey = key
    }
  }
  else {
    mask = null
    maskKey = ''
  }

  applyRange(base.data, out.data, lut, mask, adjustments.saturation, 0, width * height)
  ctx.putImageData(out, 0, 0)
}

/** Re-derive the preview bitmap for the current canvas size. */
async function rebuildPreview() {
  if (!source || !canvas || !ctx) return

  const scale = Math.min(canvas.width / source.width, canvas.height / source.height, 1)
  const w = Math.max(1, Math.round(source.width * scale))
  const h = Math.max(1, Math.round(source.height * scale))

  const scratch = new OffscreenCanvas(w, h)
  const sctx = scratch.getContext('2d', { willReadFrequently: true })!
  sctx.drawImage(source, 0, 0, w, h)

  base = sctx.getImageData(0, 0, w, h)
  out = new ImageData(w, h)
  maskKey = ''

  canvas.width = w
  canvas.height = h
  render()
}

const api = {
  /** Hand the visible canvas over. Called exactly once. */
  attach(offscreen: OffscreenCanvas) {
    canvas = offscreen
    ctx = offscreen.getContext('2d')
    if (!ctx) throw new Error('No 2D context on the transferred canvas')
  },

  /** The other entry point, for the fallback path in section 10. */
  attachOwned(width: number, height: number) {
    canvas = new OffscreenCanvas(width, height)
    ctx = canvas.getContext('2d')
    if (!ctx) throw new Error('No 2D context on the worker-owned canvas')
  },

  /** Blobs are cheap to clone — the bytes are not copied. Decode in here. */
  async open(file: Blob, boxWidth: number, boxHeight: number): Promise<ImageInfo> {
    source?.close()
    source = await createImageBitmap(file, { imageOrientation: 'from-image' })

    if (canvas) {
      canvas.width = boxWidth
      canvas.height = boxHeight
    }
    await rebuildPreview()

    return { width: source.width, height: source.height }
  },

  setAdjustments(next: Adjustments) {
    adjustments = next
    lut = buildLut(next)
    invalidate()
  },

  async resize(boxWidth: number, boxHeight: number) {
    if (!canvas) return
    canvas.width = boxWidth
    canvas.height = boxHeight
    await rebuildPreview()
  },

  /** Not called by the composable — terminate() covers it. Useful in tests,
   *  and for a "close image" button that keeps the worker alive. */
  dispose() {
    source?.close()
    source = null
    base = null
    out = null
    mask = null
  },
}

export type DarkroomApi = typeof api

Comlink.expose(api)

export type DarkroomApi = typeof api is the trick that makes the whole thing typed without maintaining an interface by hand. The object is the source of truth; the type is derived; the main thread imports the type and nothing else.

Some details worth pausing on.

getContext('2d', { willReadFrequently: true }) on the scratch canvas. Browsers put 2D canvases on the GPU by default, and getImageData from GPU memory means a readback stall. The hint tells the browser to keep this one in system memory because you're going to read it. It belongs on the scratch canvas — the one you read from — and not on the visible one, which you only write to.

The preview is downscaled once, then cached as base. This is the single biggest win in the file. The source might be 6000×4000; the canvas is maybe 1200×800. Every slider move loops over 960,000 pixels instead of 24,000,000 — around 25× less work — and the drawImage downscale happens once per image rather than once per frame.

out is allocated once and reused. A fresh new ImageData(w, h) per frame is 4MB of garbage per frame at preview size. That's the kind of allocation that turns into a visible GC hitch about ten seconds into a slider drag.

imageOrientation: 'from-image' applies the EXIF orientation tag. Leave it out and photos from phones arrive rotated 90°, which you'll first notice in the exported file.

source.close() before replacing it. An ImageBitmap holds decoded pixels — potentially hundreds of megabytes — and it is not garbage collected promptly just because you dropped the reference. Open five photos without closing and you can watch the tab's memory climb.

5) Creating the worker without breaking SSR

Here's the Nuxt-specific part, and it's smaller than you'd expect:

ts
// app/composables/useDarkroom.ts
import * as Comlink from 'comlink'
import type { Remote } from 'comlink'
import type { Adjustments, ExportType, ImageInfo } from '#shared/image'
import type { DarkroomApi } from '~/workers/darkroom.worker'

export function useDarkroom() {
  const info = ref<ImageInfo | null>(null)
  const busy = ref(false)
  const progress = ref(0)

  let worker: Worker | null = null
  let api: Remote<DarkroomApi> | null = null

  function boot(): Remote<DarkroomApi> {
    if (api) return api

    worker = new Worker(
      new URL('../workers/darkroom.worker.ts', import.meta.url),
      { type: 'module' },
    )
    api = Comlink.wrap<DarkroomApi>(worker)
    return api
  }

  async function attach(el: HTMLCanvasElement) {
    const offscreen = el.transferControlToOffscreen()
    // Without transfer() this is a structured clone, which throws:
    // an OffscreenCanvas can only cross a thread boundary by being moved.
    await boot().attach(Comlink.transfer(offscreen, [offscreen]))
  }

  async function open(file: Blob, box: { width: number, height: number }) {
    busy.value = true
    try {
      info.value = await boot().open(file, box.width, box.height)
    }
    finally {
      busy.value = false
    }
  }

  function setAdjustments(next: Adjustments) {
    // Not awaited. Comlink queues the call; we don't care when it lands.
    void boot().setAdjustments({ ...next })
  }

  function resize(box: { width: number, height: number }) {
    void boot().resize(box.width, box.height)
  }

  // exportImage is section 9. It belongs here, and in the return below.

  onScopeDispose(() => {
    api?.[Comlink.releaseProxy]()
    worker?.terminate()
    api = null
    worker = null
  })

  return { info, busy, progress, attach, open, setAdjustments, resize, exportImage }
}

Four things in there are load-bearing.

import type { DarkroomApi } must be a type-only import. The worker module calls Comlink.expose() at the top level. Import it normally and that module gets pulled into your main bundle and runs expose() on the window, which does nothing useful and quietly doubles your JavaScript. import type is erased at compile time and nothing is bundled. Turn on verbatimModuleSyntax if you want the compiler to enforce it.

Nothing runs at setup time. boot() is lazy and only ever called from an event handler or onMounted. There is no new Worker on the module's top level, so the composable is safe to import during SSR; if there were, the render would throw, because the Worker class doesn't exist in Node. If you prefer a hard guard, if (import.meta.server) throw new Error(...) at the top of boot() documents the intent.

Comlink.transfer(offscreen, [offscreen]). Comlink structured-clones arguments by default, and OffscreenCanvas is transfer-only — it has no clone semantics. Forget the wrapper and you get a DataCloneError at the exact moment you attach. Anything transferable that you don't want copied — ArrayBuffer, ImageBitmap, MessagePort, OffscreenCanvas — goes through transfer().

releaseProxy before terminate. Comlink keeps a message listener on the worker for the life of the proxy. Releasing detaches it so both ends can be collected; terminating kills the thread and everything it was holding — the ImageBitmap included, which is why there's no dispose() call here. Any cleanup message you send at this point races the terminate() on the next line and generally loses. Put both in onScopeDispose, which fires on unmount and when the composable is used inside an effect scope that gets stopped, unlike onUnmounted.

6) The handover is one-way

transferControlToOffscreen() is the API with the sharpest edge in this article, so let's be explicit about what it does to the element:

  • You can call it once. A second call on the same element throws InvalidStateError. Under Vue that means a v-if on the canvas which re-creates the element mid-session hands you a fresh, unattached canvas — and a worker still pointing at the old one. Keep the canvas mounted; toggle a wrapper instead.
  • It also throws if you already called getContext() on that element. A canvas that has a context can't give up ownership of it. This is the one people actually hit: some helper, some devtools poke, some earlier version of the component grabbed a 2d context, and now the transfer fails on an element that looks untouched.
  • You can never get a context from it again. el.getContext('2d') on the main thread throws InvalidStateError after the transfer. No overlays, no crosshair drawn from a mousemove handler. If you want UI on top of the picture, that's a second, ordinary canvas positioned over it.
  • You cannot set el.width or el.height any more. Those throw too. The bitmap size is the worker's property now; only the CSS size is still yours.

That last one is the resize story. CSS sizes the element, the worker sizes the bitmap, and something has to carry the number across — using the same resize from the useDarkroom() call the component already made:

ts
useResizeObserver(canvasEl, ([entry]) => {
  if (!entry) return
  const box = entry.contentRect
  resize({
    width: Math.round(box.width * window.devicePixelRatio),
    height: Math.round(box.height * window.devicePixelRatio),
  })
})

useResizeObserver comes from VueUse, which is already in most Nuxt projects; a plain ResizeObserver in onMounted does the same job. Multiplying by devicePixelRatio is what stops the result looking soft on a retina display — the CSS box is 1200px wide, the bitmap needs to be 2400.

7) Coalescing, which is why this feels different

Look again at invalidate() in the worker. A slider drag fires input events far faster than anything can render — on a trackpad, easily 100+ per second. Without coalescing you would queue 100 renders and fall progressively further behind, which is the exact failure mode that makes native-feeling web UI hard.

ts
let pending = false
function invalidate() {
  if (pending) return
  pending = true
  schedule(() => {
    pending = false
    render()
  })
}

A dirty flag and one scheduled callback, and they change the semantics completely. State updates are cheap and immediate; rendering happens at most once per frame, always from the latest state. Ten events inside one frame produce one render of the newest values — not one render of each, and not a render of stale values.

This is where doing it in the worker beats debouncing on the main thread, and the difference is worth being precise about. A debounce delays your work: you wait 50ms hoping the user stopped moving, so the preview lags the slider by 50ms even when there's headroom to render immediately. Frame coalescing drops work: it renders as fast as it can and discards intermediate states it was too slow to draw. The preview never lags; it just skips.

You get one more thing for free that the main thread cannot offer. If a render overruns its frame, the worker's event loop is the one that stalls — the slider keeps moving, because it isn't there.

8) The component

Everything above adds up to a component with essentially no logic in it:

vue
<!-- app/pages/index.vue -->
<script setup lang="ts">
import { NEUTRAL } from '#shared/image'
import type { Adjustments } from '#shared/image'

const canvasEl = ref<HTMLCanvasElement | null>(null)
const adjustments = reactive<Adjustments>({ ...NEUTRAL })

const { info, busy, progress, attach, open, setAdjustments, resize, exportImage }
  = useDarkroom()

function box() {
  const el = canvasEl.value!
  const dpr = window.devicePixelRatio || 1
  return {
    width: Math.round(el.clientWidth * dpr),
    height: Math.round(el.clientHeight * dpr),
  }
}

onMounted(async () => {
  await attach(canvasEl.value!)
})

useResizeObserver(canvasEl, () => resize(box()))

// One watcher, no debounce — the worker decides when to draw.
watch(adjustments, () => setAdjustments({ ...adjustments }))

async function onPick(event: Event) {
  const file = (event.target as HTMLInputElement).files?.[0]
  if (file) await open(file, box())
}
</script>

<template>
  <main class="darkroom">
    <canvas ref="canvasEl" class="preview" />

    <aside class="controls">
      <input type="file" accept="image/*" @change="onPick">

      <label>Exposure
        <input v-model.number="adjustments.exposure" type="range" min="-2" max="2" step="0.01">
      </label>
      <label>Contrast
        <input v-model.number="adjustments.contrast" type="range" min="-100" max="100" step="1">
      </label>
      <label>Gamma
        <input v-model.number="adjustments.gamma" type="range" min="0.5" max="2.2" step="0.01">
      </label>
      <label>Saturation
        <input v-model.number="adjustments.saturation" type="range" min="0" max="2" step="0.01">
      </label>
      <label>Vignette
        <input v-model.number="adjustments.vignette" type="range" min="0" max="1" step="0.01">
      </label>

      <p v-if="info">{{ info.width }} × {{ info.height }}</p>
      <button :disabled="!info || busy" @click="exportImage()">Export</button>
      <progress v-if="busy" :value="progress" max="1" />
    </aside>
  </main>
</template>

<style scoped>
.darkroom { display: grid; grid-template-columns: 1fr 260px; gap: 1rem; height: 100dvh; }
/* object-fit matters: the worker sizes the bitmap to fit the photo's
   aspect ratio, which is rarely the box's. Without it, CSS stretches. */
.preview { width: 100%; height: 100%; display: block; object-fit: contain; background: #111; }
.controls { display: flex; flex-direction: column; gap: .75rem; }
</style>

watch(adjustments, ...) on a reactive object is deep by default, so any slider fires it. The spread is not optional: reactive objects are Proxies, and a Proxy is not structured cloneable. Pass adjustments directly and you get a DataCloneError on the first drag. { ...adjustments } produces a plain object of five numbers, which is exactly what the boundary wants.

toRaw(adjustments) also works and avoids the copy, but it hands the worker a live reference to reactive state — fine today, and a subtle bug the day someone adds a nested object to Adjustments. The spread is five numbers; take the copy.

Note what's missing from this component: no requestAnimationFrame, no debounce, no getContext, no ImageData, no worker lifecycle. The main thread's entire job is turning a slider into five numbers.

9) Full-resolution export, with progress

The preview is downscaled. The export can't be — and at full resolution the pass takes long enough that you owe the user a progress bar. This is where Comlink.proxy earns its place.

Add to the worker's api:

ts
  async exportImage(
    type: ExportType,
    quality: number,
    onProgress: (value: number) => void,
  ): Promise<Blob> {
    if (!source) throw new Error('No image open')

    const { width, height } = source
    const full = new OffscreenCanvas(width, height)
    const fctx = full.getContext('2d', { willReadFrequently: true })!
    fctx.drawImage(source, 0, 0)

    const src = fctx.getImageData(0, 0, width, height)
    const dst = new ImageData(width, height)

    const total = width * height
    const bandMask = adjustments.vignette > 0
      ? buildVignette(width, height, adjustments.vignette)
      : null

    // ~8 bands: small enough to report often, large enough that the
    // per-band overhead is noise.
    const band = Math.ceil(total / 8)

    for (let start = 0; start < total; start += band) {
      const end = Math.min(start + band, total)
      applyRange(src.data, dst.data, lut, bandMask, adjustments.saturation, start, end)

      onProgress(end / total)
      // Yield, so this thread can service its own inbox between bands.
      await new Promise(resolve => setTimeout(resolve, 0))
    }

    fctx.putImageData(dst, 0, 0)
    return await full.convertToBlob({ type, quality })
  },

And inside useDarkroom, next to open and resize:

ts
  async function exportImage(type: ExportType = 'image/jpeg', quality = 0.92) {
    busy.value = true
    progress.value = 0
    try {
      const blob = await boot().exportImage(
        type,
        quality,
        // A function is neither cloneable nor transferable. proxy() sends
        // a handle instead, and Comlink calls back across the channel.
        Comlink.proxy((value: number) => { progress.value = value }),
      )
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = `darkroom.${type === 'image/png' ? 'png' : type === 'image/webp' ? 'webp' : 'jpg'}`
      a.click()
      // Revoking on the same tick can cancel the download. Give it one turn.
      setTimeout(() => URL.revokeObjectURL(url), 1000)
    }
    finally {
      busy.value = false
    }
  }

Three things here that are easy to get wrong.

The await inside the loop is doing real work, but not the work you'd guess. The progress messages get delivered either way — postMessage queues a task on the receiving thread immediately, so the sender doesn't have to unwind first. What the yield buys is the worker servicing its own inbox. Without it, one export monopolises the thread from first band to last: no setAdjustments, no resize, and crucially no way to cancel. With it, you check a flag at the top of each band and exportImage becomes abortable in eight lines.

Progress is not a promise you await. Comlink.proxy wraps a function so the worker can call back into the main thread. It's the same mechanism as the Remote<T> proxy, pointed in the other direction. Without it you get a silent no-op, because functions don't survive structured cloning and Comlink's argument serialisation quietly drops them.

convertToBlob returns a promise and does the encoding off-thread too. This is the worker equivalent of canvas.toBlob(), and it's the last piece of the pipeline that could have blocked the UI. PNG and JPEG are safe everywhere; WebP encoding is very widely supported but worth feature-detecting if it's your default. The resulting Blob is structured cloneable and cheap to send home — the bytes stay where they are and only a handle crosses.

10) The fallback path

OffscreenCanvas and transferControlToOffscreen both went Baseline in March 2023 — they shipped together — so on a current browser this path is available. Older Safari and embedded webviews are the realistic gap, and the getContext() trap from section 6 means a transfer can fail on a browser that fully supports it. Detect rather than assume:

ts
// shared/image.ts is the wrong home for this — it touches the DOM.
// app/utils/canvas.ts, and the component branches on it in onMounted.
export const canTransferCanvas
  = typeof HTMLCanvasElement !== 'undefined'
    && 'transferControlToOffscreen' in HTMLCanvasElement.prototype
ts
onMounted(async () => {
  if (canTransferCanvas) await attach(canvasEl.value!)
  else await attachFallback(canvasEl.value!, box())
})

When it's missing you don't lose the architecture — only the last step. The worker allocates its own OffscreenCanvas instead of receiving one (that's the attachOwned entry point from section 4), renders into it, and ships a finished frame home:

ts
// worker — the second render target, alongside attachOwned()
  renderToBitmap(): ImageBitmap {
    render()
    const bitmap = canvas!.transferToImageBitmap()
    return Comlink.transfer(bitmap, [bitmap])
  },
ts
// useDarkroom, replacing attach() when canTransferCanvas is false.
// Note: no transferControlToOffscreen call, so the element keeps its context.
let painter: ImageBitmapRenderingContext | null = null

async function attachFallback(el: HTMLCanvasElement, box: { width: number, height: number }) {
  painter = el.getContext('bitmaprenderer')
  el.width = box.width
  el.height = box.height
  await boot().attachOwned(box.width, box.height)
}

async function paint() {
  const bitmap = await boot().renderToBitmap()
  painter!.transferFromImageBitmap(bitmap)
}

paint() is the one piece the fast path doesn't need: with a transferred canvas nobody has to ask for a frame, because the worker draws straight into the thing on screen. Here you drive it yourself — call it after setAdjustments, from a main-thread requestAnimationFrame loop with the same dirty-flag coalescing from section 7.

ImageBitmapRenderingContext exists for exactly this: its only job is to swap the canvas's contents for an ImageBitmap you hand it, taking ownership rather than copying. The main thread's per-frame cost is one pointer swap. All the arithmetic still happens in the worker; you've given up the worker owning the compositing, not the worker owning the work.

Two things that bite here. transferToImageBitmap() only works on a canvas the worker created — call it on one obtained via transferControlToOffscreen and you get InvalidStateError, which is why the fallback needs its own entry point rather than reusing attach. And the Comlink.transfer(bitmap, [bitmap]) is not decoration: without it Comlink structured-clones the bitmap, which copies every pixel you just spent a frame computing. transfer(value, list) marks the members of list as transferable — an empty list transfers nothing.

If neither exists, the honest fallback is running pipeline.ts on the main thread against a small preview. It's the same module, imported normally; that's the payoff for keeping the maths free of worker APIs.

11) Proving it worked

"Feels smoother" isn't a measurement. Two ways to get a number.

Open DevTools → Performance, record a slider drag, and look at the flame chart. You want the main thread's track to be nearly empty during the drag — a thin row of input handlers — while a second track named after your worker does the real work. Before the change, you'd see 40–100ms blocks of scripting on the main thread, each one a dropped frame.

Or count long tasks in the app itself:

ts
// app/plugins/longtasks.client.ts
export default defineNuxtPlugin(() => {
  if (!import.meta.dev || !('PerformanceObserver' in window)) return

  new PerformanceObserver((list) => {
    for (const entry of list.getEntries())
      console.warn(`Long task: ${Math.round(entry.duration)}ms`)
  }).observe({ type: 'longtask', buffered: true })
})

Anything over 50ms is a long task by definition, and during a slider drag you want zero of them. The .client.ts suffix keeps the plugin out of the server build, where PerformanceObserver and window don't exist. One caveat before you celebrate: longtask entries are Chromium-only. Firefox and Safari accept the observe() call and report nothing, so a clean console there means the observer isn't running, not that the page is fast.

Worth knowing while you're in there: workers show up in DevTools as separate targets. Chrome's Sources panel lists them under Threads, breakpoints work normally, and console.log from a worker appears in the main console prefixed with the worker's URL. The old reputation of workers as undebuggable is a decade out of date.

Where to take it

  • A WebGL or WebGPU context in the worker. OffscreenCanvas.getContext('webgl2') works, and shaders would make this pipeline real-time on 24MP. That's the natural sequel to the Three.js WebGPU build — same rendering ideas, moved off the main thread.
  • A pool of workers. Split the image into horizontal bands, one worker per band, navigator.hardwareConcurrency of them. applyRange already takes a pixel range, so the pipeline needs no changes at all — only the orchestration.
  • SharedArrayBuffer removes even the transfer, letting several workers write into one pixel buffer. It needs COOP/COEP headers, which is a Nitro routeRules change and a real constraint on what you can embed.
  • Undo, as a list of Adjustments. The pipeline is a pure function of source plus five numbers, so history is an array of small objects and undo is an index. Non-destructive editing falls out of the architecture rather than being built.
  • nuxt-workers by Daniel Roe auto-imports worker functions and handles the SSR boundary for you. It's the right tool when you want a function moved off-thread and don't need the canvas ownership this article is built around.
  • Test pipeline.ts in Vitest. No worker, no canvas, no DOM — feed it a Uint8ClampedArray, assert on bytes. Pure functions over typed arrays are the easiest thing in the codebase to test, which is the other reason they're in their own file.

Wrapping up

The architecture is four files: a contract in shared/, pure functions over pixels, a worker that owns the state and the canvas, and a composable that boots it. The component below them has no idea any of it exists — it renders sliders.

Three things to carry into your own build. Put the boundary in a shared/ type and derive the RPC surface with typeof api rather than maintaining an interface by hand. Reach for Comlink.transfer for anything transferable and Comlink.proxy for anything callable, because structured cloning silently drops the second and refuses the first. And coalesce renders with a dirty flag instead of debouncing input — you want to skip frames you were too slow for, not delay the ones you could have drawn.

The deeper point is that "make it faster" and "make it not block" are different problems with different fixes. This pipeline isn't dramatically faster than the naive version. It's just somewhere else.

Sources