loadFromJSON creates every object in one go, so a floor plan or a long design with thousands of objects freezes the tab until it is done. Fabric.js performance on load matters most for these large documents, because the user sees nothing and cannot cancel. The engine loads in chunks, reports progress and stops when asked.
Loading the live demo. Its code is below.
"use client";
import {
type FabricDocument,
isDocumentEngineError,
type LoadProgress,
type SerializedFabricObject,
} from "fabricjs-document-engine";
import { useDocumentEngine } from "fabricjs-document-engine/react";
import { CircleStopIcon, FolderOpenIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import {
COLORS,
DemoCanvas,
DemoFrame,
Status,
Toolbar,
ToolButton,
} from "./demo-ui";
import { PAGE, useFabricCanvas } from "./use-fabric-canvas";
const COUNT = 20_000;
/** A document with thousands of small shapes, like a busy floor plan. */
function bigDocument(): FabricDocument {
const objects: SerializedFabricObject[] = Array.from(
{ length: COUNT },
(_, index) => ({
type: index % 3 === 0 ? "Circle" : "Rect",
originX: "left",
originY: "top",
left: Math.random() * (PAGE.width - 8),
top: Math.random() * (PAGE.height - 8),
width: 6,
height: 6,
radius: 3,
fill: COLORS[index % COLORS.length],
objectCaching: false,
})
);
return {
schemaVersion: 1,
id: `floor-plan-${Date.now()}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
canvas: { width: PAGE.width, height: PAGE.height, background: "#ffffff" },
objects,
metadata: {},
};
}
export default function LargeDocumentDemo() {
const t = useTranslations("demos");
const { elementRef, canvas } = useFabricCanvas();
const engine = useDocumentEngine(canvas);
const controller = useRef<AbortController | null>(null);
const [progress, setProgress] = useState<LoadProgress | null>(null);
const [message, setMessage] = useState("");
const loading =
controller.current !== null &&
progress !== null &&
progress.stage !== "done";
async function load() {
if (!engine) return;
const current = new AbortController();
controller.current = current;
const started = performance.now();
setMessage("");
try {
await engine.loadDocument(bigDocument(), {
signal: current.signal,
onProgress: setProgress,
discardUnsavedChanges: true,
});
setMessage(
t("large.done", {
count: COUNT,
ms: Math.round(performance.now() - started),
})
);
} catch (error) {
setMessage(
isDocumentEngineError(error) && error.code === "LOAD_ABORTED"
? t("large.cancelled")
: t("large.failed", { message: String(error) })
);
} finally {
controller.current = null;
setProgress(null);
}
}
const share =
progress && progress.total > 0 ? progress.done / progress.total : 0;
return (
<DemoFrame>
<DemoCanvas elementRef={elementRef} label={t("canvas")} />
<div
role="progressbar"
aria-label={t("large.progress")}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(share * 100)}
className="h-1.5 overflow-hidden rounded-full bg-muted"
>
<div
className="h-full bg-foreground transition-[width]"
style={{ width: `${share * 100}%` }}
/>
</div>
<Toolbar>
<ToolButton
icon={FolderOpenIcon}
label={t("large.load", { count: COUNT.toLocaleString() })}
primary
disabled={!engine || loading}
onClick={() => void load()}
/>
<ToolButton
icon={CircleStopIcon}
label={t("large.cancel")}
disabled={!loading}
onClick={() => controller.current?.abort()}
/>
</Toolbar>
<Status tone={loading ? "busy" : message ? "ok" : "idle"}>
{progress
? t("large.stage", {
stage: t(`large.stages.${progress.stage}`),
done: progress.done,
total: progress.total,
})
: message || t("large.hint")}
</Status>
</DemoFrame>
);
}Show load progress and cancel
const controller = new AbortController();
cancelButton.onclick = () => controller.abort();
await engine.load("floor-plan", {
signal: controller.signal,
onProgress: ({ stage, done, total }) => {
progressBar.value = total > 0 ? done / total : 0;
progressLabel.textContent = stage;
},
});stageisprepare(validation and migration),images(images checked),objects(objects created) anddone.- Objects are created 100 at a time, and the page gets a turn between chunks, so clicks and scrolling keep working.
- Cancelling the
AbortSignalrejects withLOAD_ABORTED, and the canvas keeps showing what it showed before. A failed load does the same, because every object is created before the canvas is cleared. - The
load:progressevent carries the same values for code that is not the caller.
signal and onProgress work for load, loadDocument, importFabricJson and restoreRecovery.
Why an image failed
A load stops with MISSING_ASSETS when images are missing, and each entry now says why:
try {
await engine.load("floor-plan");
} catch (error) {
if (isDocumentEngineError(error) && error.code === "MISSING_ASSETS") {
for (const { url, failure } of error.missingAssets) {
console.warn(url, failure?.reason, failure?.status);
}
}
}reason | Meaning |
|---|---|
NOT_FOUND | The server answered 404 or 410 |
HTTP_ERROR | The server answered with another error status |
CORS | Another site does not allow your page to read the image |
NETWORK | The address could not be reached |
TIMEOUT | The image took longer than assets.imageTimeout |
DECODE | The file arrived, but it is not a picture the browser can read |
ABORTED | The load was cancelled |
Browsers hide some details on purpose. When an image from another site fails without CORS headers, the reason is CORS if the image asked for crossOrigin, and NETWORK otherwise.
Tune image loading
createDocumentEngine({
canvas,
assets: {
imageTimeout: 15_000, // default 30 seconds
maxConcurrentImages: 4, // default 6
},
});Fewer images at once helps when many large images compete on a slow connection. More Fabric.js performance figures are on the compatibility page, and missing images are covered in images and fonts. To render many documents rather than open one large one, see rendering many documents.