# Large documents

> Fabric.js performance for large documents: objects load in chunks so the page stays responsive, with progress, cancelling and a reason for every failed image.

Source: https://fabricjs-document-engine.jscrate.dev/docs/guides/large-documents
Last updated: 2026-10-01

`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.

```tsx
"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>
  );
}
```

```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>
  );
}
```

## Show load progress and cancel

```ts
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;
  },
});
```

- `stage` is `prepare` (validation and migration), `images` (images checked), `objects` (objects created) and `done`.
- Objects are created 100 at a time, and the page gets a turn between chunks, so clicks and scrolling keep working.
- Cancelling the `AbortSignal` rejects with `LOAD_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:progress` event 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:

```ts
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

```ts
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](https://fabricjs-document-engine.jscrate.dev/docs/production/compatibility) page, and missing images are covered in [images and fonts](https://fabricjs-document-engine.jscrate.dev/docs/guides/assets-and-fonts). To render many documents rather than open one large one, see [rendering many documents](https://fabricjs-document-engine.jscrate.dev/docs/guides/batch-rendering).
