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). 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
Loading the live demo. Its code is below.
"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>
);
}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
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
OffscreenCanvasand 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. applyresolvestruewhen 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.
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
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.
const filterWorker = createFilterWorker({ worker: false });All options are in the Filters API. For slow drawing with many objects, see Rendering many objects. Fabric.js image filters keep their filters array, so saved documents open with the same look.