# Image filters

> Run Fabric.js image filters in a Web Worker: blur and color filters with progress and cancel, the same pixels as Fabric, and a page that never freezes.

Source: https://fabricjs-document-engine.jscrate.dev/docs/guides/image-filters
Last updated: 2026-10-02

`FabricImage.applyFilters()` is synchronous. It runs every filter over every pixel before the page can paint or answer a click, so a blur on a large photo freezes the editor ([fabric.js #9532](https://github.com/fabricjs/fabric.js/issues/9532)). Fabric's WebGL backend is faster on some filters, but the work still happens on the main thread, and slow GPUs or lost WebGL contexts fall back to the 2D path. `createFilterWorker` runs Fabric.js image filters in a Web Worker instead.

## Try it

```tsx
"use client";

import { FabricImage, filters } from "fabric";
import {
  createFilterWorker,
  type FilterWorker,
  type ImageFilter,
} from "fabricjs-document-engine/filters";
import { CpuIcon, RotateCcwIcon, SnailIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";

import { DemoCanvas, DemoFrame, Status, Toolbar, ToolButton } from "./demo-ui";
import { PAGE, useFabricCanvas } from "./use-fabric-canvas";

const SOURCE = { width: 2400, height: 1500 };

function photo() {
  const element = document.createElement("canvas");
  element.width = SOURCE.width;
  element.height = SOURCE.height;
  const ctx = element.getContext("2d")!;
  const sky = ctx.createLinearGradient(0, 0, 0, SOURCE.height);
  sky.addColorStop(0, "#7ad9ec");
  sky.addColorStop(0.6, "#ffd666");
  sky.addColorStop(1, "#f2836f");
  ctx.fillStyle = sky;
  ctx.fillRect(0, 0, SOURCE.width, SOURCE.height);
  for (let index = 0; index < 600; index += 1) {
    ctx.fillStyle = `hsl(${(index * 47) % 360} 70% ${40 + (index % 30)}%)`;
    ctx.beginPath();
    ctx.arc(
      (index * 389) % SOURCE.width,
      (index * 251) % SOURCE.height,
      10 + (index % 50),
      0,
      Math.PI * 2
    );
    ctx.fill();
  }
  return element.toDataURL("image/jpeg", 0.9);
}

const heavy = (): ImageFilter[] => [
  new filters.Blur({ blur: 0.25 }),
  new filters.Convolute({ matrix: [0, -1, 0, -1, 5, -1, 0, -1, 0] }),
  new filters.Saturation({ saturation: 0.4 }),
];

function measureFreeze(work: () => Promise<unknown>) {
  const channel = new MessageChannel();
  let last = performance.now();
  let longest = 0;
  let running = true;
  channel.port1.onmessage = () => {
    const now = performance.now();
    longest = Math.max(longest, now - last);
    last = now;
    if (running) channel.port2.postMessage(null);
  };
  channel.port2.postMessage(null);
  return work().then(() => {
    running = false;
    channel.port1.close();
    return Math.round(Math.max(longest, performance.now() - last));
  });
}

export default function FilterWorkerDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const imageRef = useRef<FabricImage | null>(null);
  const workerRef = useRef<FilterWorker | null>(null);
  const spinnerRef = useRef<HTMLSpanElement>(null);
  const [busy, setBusy] = useState(false);
  const [progress, setProgress] = useState(0);
  const [message, setMessage] = useState("");

  useEffect(() => {
    if (!canvas) return;
    let cancelled = false;
    const worker = createFilterWorker();
    workerRef.current = worker;
    void FabricImage.fromURL(photo()).then((image) => {
      if (cancelled) return;
      image.scaleToWidth(PAGE.width);
      image.set({
        left: 0,
        top: 0,
        originX: "left",
        originY: "top",
        selectable: false,
      });
      imageRef.current = image;
      canvas.add(image);
      canvas.requestRenderAll();
    });
    let frame = 0;
    let angle = 0;
    const spin = () => {
      angle = (angle + 6) % 360;
      if (spinnerRef.current)
        spinnerRef.current.style.transform = `rotate(${angle}deg)`;
      frame = requestAnimationFrame(spin);
    };
    frame = requestAnimationFrame(spin);
    return () => {
      cancelled = true;
      cancelAnimationFrame(frame);
      worker.terminate();
      if (imageRef.current) canvas.remove(imageRef.current);
      imageRef.current = null;
    };
  }, [canvas]);

  async function run(inWorker: boolean) {
    const image = imageRef.current;
    const worker = workerRef.current;
    if (!canvas || !image || !worker || busy) return;
    setBusy(true);
    setProgress(0);
    setMessage(t("filters.running"));
    await new Promise((resolve) => setTimeout(resolve, 50));
    const freeze = await measureFreeze(async () => {
      if (inWorker) {
        await worker.apply(image, heavy(), { onProgress: setProgress });
      } else {
        image.filters = heavy() as never;
        image.applyFilters();
        setProgress(1);
      }
      canvas.requestRenderAll();
    });
    setMessage(
      inWorker
        ? t("filters.workerResult", { freeze, mode: worker.mode })
        : t("filters.fabricResult", { freeze })
    );
    setBusy(false);
  }

  async function reset() {
    const image = imageRef.current;
    if (!canvas || !image || !workerRef.current) return;
    await workerRef.current.apply(image, []);
    setProgress(0);
    setMessage("");
  }

  return (
    <DemoFrame>
      <DemoCanvas elementRef={elementRef} label={t("canvas")}>
        <span
          ref={spinnerRef}
          aria-hidden
          className="absolute top-2 right-2 size-5 rounded-full border-2 border-[#6f00ff] border-t-transparent"
        />
      </DemoCanvas>

      <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
        <div
          className="h-full bg-[#6f00ff] transition-[width]"
          style={{ width: `${Math.round(progress * 100)}%` }}
        />
      </div>

      <Toolbar>
        <ToolButton
          icon={CpuIcon}
          label={t("filters.worker")}
          primary
          disabled={!canvas || busy}
          onClick={() => void run(true)}
        />
        <ToolButton
          icon={SnailIcon}
          label={t("filters.fabric")}
          disabled={!canvas || busy}
          onClick={() => void run(false)}
        />
        <ToolButton
          icon={RotateCcwIcon}
          label={t("filters.reset")}
          iconOnly
          disabled={!canvas || busy}
          onClick={() => void reset()}
        />
      </Toolbar>

      <Status tone={busy ? "busy" : message ? "ok" : "idle"}>
        {message || t("filters.hint")}
      </Status>
    </DemoFrame>
  );
}
```

```tsx
import { Canvas } from "fabric";
import { useLayoutEffect, useRef, useState } from "react";

/**
 * The page every demo draws on. The document keeps this size; CSS scales the
 * canvas down to fit its card, so saved documents and exports never depend on
 * the screen.
 */
export const PAGE = { width: 480, height: 300 };

// Selection handles in the site's style. Fabric does not save these.
const SELECTION = {
  borderColor: "#6f00ff",
  borderScaleFactor: 1.5,
  borderOpacityWhenMoving: 0.5,
  cornerStyle: "circle",
  cornerSize: 11,
  cornerColor: "#ffffff",
  cornerStrokeColor: "#6f00ff",
  transparentCorners: false,
} as const;

export function styleSelection(canvas: Canvas) {
  canvas.set({
    selectionColor: "rgba(111, 0, 255, 0.06)",
    selectionBorderColor: "#6f00ff",
    selectionLineWidth: 1,
  });
  const style = () => canvas.getActiveObject()?.set(SELECTION);
  canvas.on("selection:created", style);
  canvas.on("selection:updated", style);
}

/**
 * Creates a Fabric.js canvas once the `<canvas>` element exists, and disposes
 * it on unmount. The engine is created from the returned `canvas`.
 */
export function useFabricCanvas({ width, height } = PAGE) {
  const elementRef = useRef<HTMLCanvasElement>(null);
  const [canvas, setCanvas] = useState<Canvas | null>(null);

  // A layout effect creates the canvas before the first paint.
  useLayoutEffect(() => {
    const created = new Canvas(elementRef.current!, {
      width,
      height,
      backgroundColor: "#ffffff",
    });
    styleSelection(created);
    setCanvas(created);

    return () => {
      // Dispose after the engine's own cleanup, so its last recovery copy
      // still sees the objects.
      setTimeout(() => created.dispose().catch(() => undefined));
    };
  }, [width, height]);

  return { elementRef, canvas };
}
```

```tsx
import { Circle, type FabricObject, Rect, Triangle } from "fabric";
import type { LucideIcon } from "lucide-react";

import { cn } from "@/lib/utils";

import { PAGE } from "./use-fabric-canvas";

export const COLORS = ["#f2836f", "#5fe6c4", "#7ad9ec", "#6f00ff", "#ffd666"];

// Demo shapes are placed by their top-left corner, like a design tool.
const CORNER = { originX: "left", originY: "top" } as const;

/** A new shape at a random spot on the page. */
export function newShape(kind: "square" | "circle", fill?: string) {
  const left = 40 + Math.random() * (PAGE.width - 160);
  const top = 30 + Math.random() * (PAGE.height - 120);
  const color = fill ?? COLORS[Math.floor(Math.random() * COLORS.length)];
  return kind === "square"
    ? new Rect({
        ...CORNER,
        left,
        top,
        width: 88,
        height: 64,
        rx: 10,
        ry: 10,
        fill: color,
      })
    : new Circle({ ...CORNER, left, top, radius: 34, fill: color });
}

/** A small composed drawing, so no demo starts on an empty page. */
export function seedShapes(): FabricObject[] {
  return [
    new Rect({
      ...CORNER,
      left: 48,
      top: 56,
      width: 176,
      height: 124,
      rx: 16,
      ry: 16,
      fill: COLORS[4],
    }),
    new Circle({ ...CORNER, left: 268, top: 44, radius: 58, fill: COLORS[0] }),
    new Rect({
      ...CORNER,
      left: 256,
      top: 196,
      width: 176,
      height: 52,
      rx: 26,
      ry: 26,
      fill: COLORS[1],
    }),
    new Triangle({
      ...CORNER,
      left: 96,
      top: 196,
      width: 72,
      height: 60,
      fill: COLORS[3],
    }),
  ];
}

export function DemoFrame({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      className={cn(
        "flex w-full max-w-md flex-col gap-3 text-left outline-none",
        className
      )}
      {...props}
    />
  );
}

/**
 * Scales the fixed-size Fabric canvas to the frame's width. The rules are
 * `!important` because Fabric writes its sizes as inline styles.
 */
export function DemoCanvas({
  elementRef,
  label,
  page = PAGE,
  children,
}: {
  elementRef: React.Ref<HTMLCanvasElement>;
  label: string;
  page?: { width: number; height: number };
  children?: React.ReactNode;
}) {
  return (
    <div
      className="relative overflow-hidden rounded-lg border bg-white shadow-xs [&_.canvas-container]:aspect-(--page)! [&_.canvas-container]:h-auto! [&_.canvas-container]:w-full! [&_canvas]:h-full! [&_canvas]:w-full!"
      style={
        { "--page": `${page.width} / ${page.height}` } as React.CSSProperties
      }
    >
      <div>
        <canvas ref={elementRef} aria-label={label} />
      </div>
      {children}
    </div>
  );
}

export function Toolbar({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      className={cn("flex flex-wrap items-center gap-1.5", className)}
      {...props}
    />
  );
}

export function ToolButton({
  icon: Icon,
  label,
  iconOnly = false,
  primary = false,
  className,
  ...props
}: React.ComponentProps<"button"> & {
  icon: LucideIcon;
  label: string;
  iconOnly?: boolean;
  primary?: boolean;
}) {
  return (
    <button
      type="button"
      title={label}
      aria-label={iconOnly ? label : undefined}
      className={cn(
        "inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md border px-2.5 text-[13px] font-medium whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0",
        primary
          ? "border-transparent bg-foreground text-background hover:bg-foreground/85"
          : "bg-background hover:bg-muted",
        iconOnly && "w-8 px-0",
        className
      )}
      {...props}
    >
      <Icon aria-hidden />
      {!iconOnly && <span className="truncate">{label}</span>}
    </button>
  );
}

const TONES = {
  idle: "bg-zinc-400",
  busy: "bg-amber-400",
  ok: "bg-emerald-500",
  error: "bg-red-500",
};

/** One line of status text. Its height never changes, so the card never jumps. */
export function Status({
  tone = "idle",
  children,
}: {
  tone?: keyof typeof TONES;
  children: React.ReactNode;
}) {
  return (
    <p
      role="status"
      className="flex h-5 min-w-0 items-center gap-2 text-[13px] text-muted-foreground"
    >
      <span
        aria-hidden
        className={cn("size-1.5 shrink-0 rounded-full", TONES[tone])}
      />
      <span className="truncate">{children}</span>
    </p>
  );
}
```

Run the same blur in the worker and with Fabric, and watch the spinner in the corner. It stops whenever the page is frozen. The status shows the longest freeze in milliseconds.

## Run filters in a worker

```ts
import { filters } from "fabric";
import { createFilterWorker } from "fabricjs-document-engine/filters";

const filterWorker = createFilterWorker({ engine });

await filterWorker.apply(image, [
  new filters.Blur({ blur: 0.2 }),
  new filters.Brightness({ brightness: 0.1 }),
]);
```

- The picture goes to the worker as an `ImageBitmap`, and the result comes back the same way. Both are transferred, not copied.
- The worker draws on an `OffscreenCanvas` and uses Fabric's own filter classes, so the result has the same pixels as Fabric's 2D filters.
- The image only changes when the result is ready. With `engine`, the change is one undo step and saves with the document.
- `apply` resolves `true` when the image was updated.

## Progress and newest run wins

Filters that change each pixel on its own, such as brightness, contrast and color matrices, run together over bands of rows, with a progress report after each band. Blur, convolute and pixelate run over the whole picture as one step.

```ts
slider.addEventListener("input", () => {
  void filterWorker.apply(
    image,
    [new filters.Blur({ blur: Number(slider.value) })],
    {
      onProgress: (done) => (progress.value = done),
    }
  );
});
```

While a slider moves, each new run for an image cancels the one before it, and the older promise resolves `false`. Only the last value is drawn.

## Cancel

```ts
const controller = new AbortController();
const run = filterWorker.apply(image, heavyFilters, {
  signal: controller.signal,
});
controller.abort();
await run.catch(() => undefined);
```

An aborted run rejects with the signal's reason and leaves the image as it was.

## Where it runs

| Situation                                                                            | Where the filters run                                      |
| ------------------------------------------------------------------------------------ | ---------------------------------------------------------- |
| Module workers and `OffscreenCanvas` (Chrome 80, Firefox 114, Safari 16.4 and later) | In a Web Worker                                            |
| Older browsers, or the CommonJS build                                                | On the main thread, in short steps with a pause after each |
| BlendImage, Resize or your own filter classes                                        | With Fabric's `applyFilters`                               |

`filterWorker.mode` says which one is in use. Vite, webpack and Next.js pick up the worker file on their own. To run on the main thread on purpose, for example in tests, pass `worker: false`; `createWorker` lets you make the worker yourself in other setups.

```ts
const filterWorker = createFilterWorker({ worker: false });
```

All options are in the [Filters API](https://fabricjs-document-engine.jscrate.dev/docs/api/filters-api). For slow drawing with many objects, see [Rendering many objects](https://fabricjs-document-engine.jscrate.dev/docs/guides/rendering-performance). Fabric.js image filters keep their `filters` array, so saved documents open with the same look.
