Skip to content

Browser and Web Worker Guide ​

Use @imagemin-rs/wasm when image bytes already live in a browser and the application does not need Node.js paths, globbing, native bindings, or executable sidecars. The package is asynchronous and memory-only: it accepts and returns Uint8Array values.

This guide covers a direct call for small jobs and a production-oriented module Worker for responsive applications. The complete runnable project is in examples/browser-wasm.

Choose the runtime ​

RequirementBrowser WASMNode package
In-memory GIF, PNG, or SVGYesYes
File paths, globs, destination foldersNoYes
giflossless, oxipng, optipngYesYes
svgmYesYes
Gifsicle, pngquant, JPEG, WebP, AVIFNoYes
Hard cancellationTerminate a Web WorkerProcess/worker policy varies
Browser-local processingYes; no upload is requiredNo; runs in the Node process

Install ​

sh
pnpm add @imagemin-rs/wasm

Direct use for a small job ​

Calling the API on the main thread is convenient for a small image or a controlled one-off task. Initialize once, pass bytes to optimize(), and turn the result back into a Blob if the browser should display or download it.

ts
import { giflossless, initWasm, optimize, oxipng, svgm } from "@imagemin-rs/wasm";

import type { ImageKind } from "./messages";

const initialized = initWasm();

export async function optimizeOnMainThread(
  bytes: Uint8Array,
  kind: ImageKind,
  signal?: AbortSignal,
): Promise<Uint8Array> {
  await initialized;

  const plugin =
    kind === "gif"
      ? giflossless({ strip: true })
      : kind === "svg"
        ? svgm({ preset: "safe" })
        : oxipng({ optimizationLevel: 3, strip: "safe" });
  const result = await optimize(bytes, {
    plugins: [plugin],
    ...(signal === undefined ? {} : { signal }),
  });
  return result.data;
}

AbortSignal rejects queued work and is checked between plugins. A codec call that is already executing inside WebAssembly is synchronous and cannot be preempted by the signal. Use a Worker when the page must remain responsive or when cancellation must stop active computation.

Run the complete Worker example ​

From a repository checkout:

sh
pnpm install
pnpm example:browser:dev

Open the local URL printed by Vite, select a PNG, animated GIF, or SVG, then optimize and download it. To verify the production bundle:

sh
pnpm example:browser:build

The build checks TypeScript and emits the main JavaScript, a separate module Worker, and a hashed .wasm asset. The example transfers ArrayBuffer ownership between threads so image data is not copied by structured clone.

ts
import "./style.css";

import type { ImageKind, OptimizeRequest, OptimizeResponse } from "./messages";

const form = requiredElement<HTMLFormElement>("optimizer");
const input = requiredElement<HTMLInputElement>("image");
const optimizeButton = requiredElement<HTMLButtonElement>("optimize");
const cancelButton = requiredElement<HTMLButtonElement>("cancel");
const status = requiredElement<HTMLElement>("status");
const metrics = requiredElement<HTMLElement>("metrics");
const inputBytes = requiredElement<HTMLElement>("input-bytes");
const outputBytes = requiredElement<HTMLElement>("output-bytes");
const codec = requiredElement<HTMLElement>("codec");
const download = requiredElement<HTMLAnchorElement>("download");

let activeRequestId: number | undefined;
let downloadUrl: string | undefined;
let nextRequestId = 0;
let selectedFile: File | undefined;
let worker: Worker | undefined;

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const file = input.files?.[0];
  if (file === undefined) {
    setStatus("Choose a PNG, GIF, or SVG first.", true);
    return;
  }

  const kind = imageKind(file);
  if (kind === undefined) {
    setStatus("This example accepts PNG, GIF, and SVG files.", true);
    return;
  }

  setBusy(true);
  clearDownload();
  selectedFile = file;
  const id = ++nextRequestId;
  activeRequestId = id;

  try {
    const bytes = await file.arrayBuffer();
    if (activeRequestId !== id) return;

    const request: OptimizeRequest = { bytes, id, kind };
    getWorker().postMessage(request, [bytes]);
    setStatus(`Optimizing ${file.name}…`);
  } catch (error) {
    finishWithError(error);
  }
});

cancelButton.addEventListener("click", () => {
  if (activeRequestId === undefined) return;
  resetWorker();
  activeRequestId = undefined;
  setBusy(false);
  setStatus("Optimization canceled. The Worker was terminated.");
});

window.addEventListener("pagehide", () => {
  resetWorker();
  clearDownload();
});

function getWorker(): Worker {
  if (worker !== undefined) return worker;

  worker = new Worker(new URL("./image-worker.ts", import.meta.url), {
    name: "imagemin-rs-example",
    type: "module",
  });
  worker.addEventListener("message", onWorkerMessage);
  worker.addEventListener("error", (event) => {
    finishWithError(event.error instanceof Error ? event.error : new Error(event.message));
    resetWorker();
  });
  return worker;
}

function onWorkerMessage(event: MessageEvent<OptimizeResponse>): void {
  const response = event.data;
  if (response.id !== activeRequestId) return;

  activeRequestId = undefined;
  setBusy(false);

  if (!response.ok) {
    const prefix = [response.code, response.plugin].filter(Boolean).join(" / ");
    setStatus(`${prefix ? `${prefix}: ` : ""}${response.message}`, true);
    return;
  }

  const file = selectedFile;
  if (file === undefined) return;

  const blob = new Blob([response.bytes], { type: outputMimeType(file) });
  downloadUrl = URL.createObjectURL(blob);
  download.href = downloadUrl;
  download.download = outputName(file.name);
  download.hidden = false;

  inputBytes.textContent = formatBytes(response.inputBytes);
  outputBytes.textContent = formatBytes(response.outputBytes);
  codec.textContent = response.codec;
  metrics.hidden = false;
  setStatus(`Finished ${file.name}.`);
}

function finishWithError(error: unknown): void {
  activeRequestId = undefined;
  setBusy(false);
  setStatus(error instanceof Error ? error.message : String(error), true);
}

function resetWorker(): void {
  worker?.terminate();
  worker = undefined;
}

function clearDownload(): void {
  if (downloadUrl !== undefined) URL.revokeObjectURL(downloadUrl);
  downloadUrl = undefined;
  download.hidden = true;
  download.removeAttribute("href");
  metrics.hidden = true;
}

function setBusy(value: boolean): void {
  input.disabled = value;
  optimizeButton.disabled = value;
  cancelButton.disabled = !value;
}

function setStatus(message: string, isError = false): void {
  status.textContent = message;
  status.dataset["error"] = String(isError);
}

function imageKind(file: File): ImageKind | undefined {
  if (file.type === "image/png" || /\.png$/iu.test(file.name)) return "png";
  if (file.type === "image/gif" || /\.gif$/iu.test(file.name)) return "gif";
  if (file.type === "image/svg+xml" || /\.svg$/iu.test(file.name)) return "svg";
  return undefined;
}

function outputMimeType(file: File): string {
  const kind = imageKind(file);
  if (kind === "gif") return "image/gif";
  if (kind === "png") return "image/png";
  if (kind === "svg") return "image/svg+xml";
  return "application/octet-stream";
}

function outputName(name: string): string {
  const dot = name.lastIndexOf(".");
  return dot > 0 ? `${name.slice(0, dot)}.optimized${name.slice(dot)}` : `${name}.optimized`;
}

function formatBytes(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  return `${(bytes / 1024).toFixed(2)} KB`;
}

function requiredElement<ElementType extends HTMLElement>(id: string): ElementType {
  const element = document.querySelector(`#${id}`);
  if (!(element instanceof HTMLElement)) throw new Error(`Missing #${id}`);
  return element as ElementType;
}
ts
import {
  giflossless,
  initWasm,
  optimize,
  oxipng,
  svgm,
  type ImageminPlugin,
} from "@imagemin-rs/wasm";

import type { ImageKind, OptimizeFailure, OptimizeRequest, OptimizeSuccess } from "./messages";

const workerScope = globalThis as unknown as DedicatedWorkerGlobalScope;

workerScope.onmessage = async (event: MessageEvent<OptimizeRequest>) => {
  const { bytes, id, kind } = event.data;

  try {
    await initWasm();
    const result = await optimize(new Uint8Array(bytes), {
      plugins: [pluginFor(kind)],
    });
    const output = toArrayBuffer(result.data);
    const response: OptimizeSuccess = {
      bytes: output,
      codec: result.steps[0]?.plugin ?? kind,
      id,
      inputBytes: result.inputBytes,
      ok: true,
      outputBytes: result.outputBytes,
    };
    workerScope.postMessage(response, [output]);
  } catch (error) {
    const response: OptimizeFailure = {
      id,
      message: error instanceof Error ? error.message : String(error),
      ok: false,
      ...readErrorContext(error),
    };
    workerScope.postMessage(response);
  }
};

function pluginFor(kind: ImageKind): ImageminPlugin {
  if (kind === "gif") return giflossless({ strip: true });
  if (kind === "svg") return svgm({ preset: "safe" });
  return oxipng({ optimizationLevel: 3, strip: "safe" });
}

function readErrorContext(error: unknown): { code?: string; plugin?: string } {
  if (error === null || typeof error !== "object") return {};

  const context: { code?: string; plugin?: string } = {};
  if ("code" in error && typeof error.code === "string") context.code = error.code;
  if ("plugin" in error && typeof error.plugin === "string") context.plugin = error.plugin;
  return context;
}

function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
  return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}

The main thread owns DOM state, downloads, and Worker lifetime. The Worker owns WASM initialization and codec execution. worker.terminate() is the hard cancellation boundary; a later request creates a clean Worker and initializes the runtime again.

File input and download flow ​

The runnable example follows this browser-only path:

  1. File.arrayBuffer() reads the selected local file.
  2. postMessage(request, [bytes]) transfers the buffer to the Worker.
  3. The Worker wraps it in Uint8Array and runs the matching built-in plugin.
  4. The optimized buffer is transferred back to the main thread.
  5. Blob and URL.createObjectURL() provide a local download.
  6. The previous object URL is revoked before another result is exposed.

Nothing in that flow uploads the image. If application code sends the bytes to a server, that is a separate application behavior rather than a requirement of @imagemin-rs/wasm.

Initialization and asset control ​

For Vite and other bundlers that understand package assets, the normal path is:

ts
import { initWasm } from "@imagemin-rs/wasm";

await initWasm();

The generated loader locates the adjacent hashed WASM asset. Applications that copy the WASM binary to a controlled URL can pass the response explicitly:

ts
const response = await fetch("/assets/imagemin_wasm_core_bg.wasm");
if (!response.ok) throw new Error(`WASM request failed: ${response.status}`);

await initWasm(response);

initWasm() also accepts a URL, byte buffer, or compiled WebAssembly.Module. Concurrent calls share one initialization promise. A failed attempt may be retried after the asset problem is fixed.

Deployment checklist ​

  • Deploy the Worker chunk and .wasm asset together with the application JavaScript; do not copy only the entry chunk.
  • Serve .wasm as application/wasm. The loader can fall back to buffered instantiation, but the correct MIME type enables streaming compilation.
  • When assets use another origin, allow that origin through CORS and verify that redirects preserve the headers.
  • Cache content-hashed Worker/WASM assets as immutable. Do not apply the same long-lived policy to HTML that points at those hashes.
  • Include the Worker and WASM locations in the application's CSP. Exact worker-src, script-src, and fetch policy depends on the deployment; test the production header rather than disabling CSP.
  • In SSR frameworks, create the Worker only on the client—for example in a mounted hook or event handler. window and Worker do not exist during server rendering.
  • Validate the final deployed URL in Chromium, Firefox, and WebKit rather than relying only on the development server.

Errors and recovery ​

Catch ImageminError and record its stable code plus optional plugin:

CodeMeaning
ERR_IMAGEMIN_WASM_LOADJS glue or the WASM asset failed to load.
ERR_IMAGEMIN_INVALID_INPUTInput is not bytes or exceeds the size limit.
ERR_IMAGEMIN_INVALID_OPTIONSA plugin option or signal is invalid.
ERR_IMAGEMIN_CODECThe selected codec rejected the image.
ERR_IMAGEMIN_ABORTEDWork was aborted between executable steps.
ERR_IMAGEMIN_PLUGINA custom browser plugin failed.

After a load failure, fix the URL, MIME, CORS, or CSP problem and call initWasm() again. After terminating a Worker, discard outstanding request IDs and create a new Worker before accepting more jobs, as the example does.

Supported browser profiles ​

FactoryInputNotes
giflossless()GIFPreserves animation frames and timing.
oxipng()PNGLossless; never keeps a larger result.
optipng()PNGOptiPNG-compatible options backed by the Rust profile.
svgm()SVGBounded safe/default native SVG profiles.

JPEG, WebP, AVIF, Gifsicle, and pngquant remain Node-only in 1.0. The Browser WASM API documents every option and the exact runtime boundary. The Playground is a deployed Worker-based application that can be used for a quick manual check.

Released under the MIT License.