DEV Community

Sohail Khan
Sohail Khan

Posted on

Why Fabric.js `loadFromJSON` Can Leave Your Editor Half-Loaded

A saved Fabric.js document can reopen with missing content when an image URL fails and the loading path allows failed objects to be omitted. fabricjs-document-engine checks external images before loading content into the canvas and rejects unresolved images with MISSING_ASSETS, including the affected URLs and object IDs.

The practical goal is simple: keep the current drawing visible, explain why the next document could not open, and let the user fix the missing asset.

This guide uses React and the public API in fabricjs-document-engine 1.0.2, checked on October 1, 2026. The native Fabric.js behavior discussed below is version-specific, with Fabric.js 7.4.0 as the concrete example. The engine supports Fabric.js 6 and 7. The quick-start guide covers installation and engine setup.

Does a failed image always leave Fabric.js partially loaded?

No. A failed image is not a universal explanation for every blank or incomplete canvas, and Fabric.js versions do not all handle failed object creation identically.

Fabric.js 7.4.0 creates objects before loadFromJSON clears and replaces the canvas. Its object-enlivening code collects failed object results and calls the reviver with the error. If the reviver supplies no replacement and throws no error, a failed object can be omitted while successful objects are installed.

That can produce a document which looks partly restored: text and shapes appear, but an image is gone. By contrast, Fabric.js 6 uses a different object-enlivening failure path; do not assume a Fabric 7 example describes every Fabric 6 failure.

The relevant primary sources are Fabric.js's loadFromJSON API, canvas implementation, and object-enlivening implementation. These two source links are pinned to the commit recorded for Fabric.js 7.4.0. Compare that implementation with the version installed in your app.

The document engine adds two relevant checks:

  • Check external assets. Fail with a missing-image report before invoking the canvas loader.
  • Refuse dropped objects. Use a strict reviver when Fabric.js creates document objects.

The package's Fabric adapter source shows the strict reviver. Its asset preparation source shows where missing-image checks run.

Why can a valid canvas JSON file still fail to reopen?

Valid JSON describes the document. It does not guarantee that every dependency still exists.

An image object may contain a src pointing to a deleted file, an expired signed URL, an unavailable host, or a blob: URL from a previous browser session. The JSON can parse successfully while the browser cannot load that image.

Symptom What to check
Shapes load but a picture disappears Failed image request; omitted object in the loading path
A document works until refresh A tab-only blob: image was saved without durable upload
An image displays but PNG export fails Canvas tainting and image CORS settings
Text uses the wrong face or layout Font availability before text creation
An older document replaces a newer selection Overlapping requests and loads
Objects appear only after clicking the canvas Rendering after the asynchronous load

A missing image and a tainted canvas need different fixes. A missing image cannot be loaded. A tainted canvas may display an image while the browser blocks reading the canvas pixels for export.

The package's images and fonts guide separates asset loading, font fallback, and CORS/export problems. The troubleshooting guide covers related editor symptoms.

Why does clearing the canvas before loading make failures worse?

This code discards the current drawing before the next document has opened:

// Fragile app code: the old drawing is removed before the load succeeds.
canvas.clear();
await canvas.loadFromJSON(savedJson);
canvas.requestRenderAll();
Enter fullscreen mode Exit fullscreen mode

If loading fails, the app has already removed the old objects. Catching the error afterward does not reconstruct those objects.

Do not clear the canvas before asking the engine to open a document. Let the engine validate and prepare the incoming content first:

await engine.loadDocument(savedDocument);
Enter fullscreen mode Exit fullscreen mode

If a storage adapter is configured, open a saved record by its document ID:

await engine.load("design-42");
Enter fullscreen mode Exit fullscreen mode

loadDocument accepts a document value. load(id) first asks the storage adapter for that value. Both use the engine's document-loading path.

See the save and load guide and document engine API for the method signatures.

What happens before the engine mutates the canvas content?

The engine prepares the incoming document before passing it to Fabric.js. With image checks enabled, the loading path does the following:

  1. Check the input. Apply content limits, migrate supported formats, and validate the document.
  2. Check object types. Refuse classes that have not been registered.
  3. Resolve asset URLs. Run an optional assets.resolveUrl handler.
  4. Check dependencies. Check external images and font availability.
  5. Try replacements. Run an optional assets.replaceMissingImage handler for missing images.
  6. Reject missing images. Throw MISSING_ASSETS if unresolved images remain.
  7. Load the content. Invoke Fabric.js with the engine's strict reviver.

For the MISSING_ASSETS failure in step 6, the canvas content has not been replaced. The error carries a list shaped like this:

error.missingAssets;
// [
//   { url: "/uploads/deleted-logo.png", objectIds: ["logo-image"] }
// ]
Enter fullscreen mode Exit fullscreen mode

This guarantee is specific to failures detected before canvas loading. It is not a claim that every possible exception, custom-class side effect, or later Fabric.js failure can be rolled back. Embedded data: images are not fetched by the external-image preflight; malformed embedded data can fail during object creation instead.

Keep image checks enabled when you want this preflight behavior. Setting assets.checkImages: false skips the external-image check and removes the early missing-asset report.

The exact order is visible in the engine loading source and the asset preparation source. The images and fonts documentation explains the public behavior.

How do you prove that a failed image leaves the current drawing intact?

Use a controlled missing-image document and compare the canvas objects before and after the rejected load. Serve the app locally and ensure the test image address returns a real failure; a development server that substitutes a valid image would defeat the test.

import { Canvas, Textbox } from "fabric";
import {
  createDocumentEngine,
  isDocumentEngineError,
  type FabricDocument,
} from "fabricjs-document-engine";

export async function demonstrateMissingImage(element: HTMLCanvasElement) {
  const canvas = new Canvas(element, { width: 800, height: 500 });
  // No storage is configured in this isolated loading demonstration.
  const engine = createDocumentEngine({ canvas });
  canvas.add(new Textbox("Keep the current drawing", { left: 40, top: 40 }));

  const currentObjects = canvas.getObjects().slice();
  const timestamp = new Date().toISOString();
  const incoming: FabricDocument = {
    schemaVersion: 1,
    id: "broken-design",
    createdAt: timestamp,
    updatedAt: timestamp,
    revision: 1,
    canvas: { width: 800, height: 500 },
    objects: [{
      type: "Image",
      id: "logo-image",
      src: "/uploads/intentionally-missing-logo.png",
      width: 120,
      height: 120,
    }],
    metadata: {},
  };

  try {
    await engine.loadDocument(incoming);
    throw new Error("The test URL unexpectedly loaded.");
  } catch (error) {
    if (!isDocumentEngineError(error) || error.code !== "MISSING_ASSETS") {
      throw error;
    }
    const after = canvas.getObjects();
    if (after.length !== currentObjects.length ||
        after.some((object, index) => object !== currentObjects[index])) {
      throw new Error("The current drawing was changed.");
    }
    return { code: error.code, missingAssets: error.missingAssets };
  } finally {
    engine.destroy();
    await canvas.dispose();
  }
}
Enter fullscreen mode Exit fullscreen mode

Use a dedicated canvas element for this helper. It creates and disposes its own canvas; do not pass the DOM element of an editor that already owns a Fabric.js instance.

The expected result is MISSING_ASSETS, the failed URL, and logo-image. The object-reference check confirms that the existing drawing survived the rejected load. The package repository contains a related browser test for missing images before canvas mutation.

This helper was run in Chromium against package 1.0.2 and Fabric.js 7.4.0. It returned MISSING_ASSETS with the expected URL and object ID, and the original canvas object references remained unchanged. A separate native Fabric.js load with one rectangle and one unavailable image restored the rectangle and omitted the image. The complete TypeScript examples in this article also passed type checking.

The example intentionally has no storage adapter so it tests image preflight directly. A persistent editor should also protect unsaved changes, as the React example below does.

How do you show the engine error in a React editor?

Catch the rejected operation and branch on the stable error code. Show missingAssets to the user instead of silently logging a generic failure.

Install the packages:

npm install fabric fabricjs-document-engine
Enter fullscreen mode Exit fullscreen mode

This component is a complete local demonstration. It uses the built-in memory adapter, so saved records last only while that adapter remains in memory. Add text, save it, and try opening the broken document.

"use client";

import { Canvas, Textbox } from "fabric";
import {
  isDocumentEngineError,
  type DocumentEngineError,
  type FabricDocument,
} from "fabricjs-document-engine";
import { createMemoryStorage } from "fabricjs-document-engine/storage";
import {
  useDocumentEngine,
  useDocumentState,
} from "fabricjs-document-engine/react";
import { useEffect, useRef, useState } from "react";

const storage = createMemoryStorage();
const timestamp = new Date().toISOString();
const brokenDocument: FabricDocument = {
  schemaVersion: 1,
  id: "broken-design",
  createdAt: timestamp,
  updatedAt: timestamp,
  revision: 1,
  canvas: { width: 800, height: 500 },
  objects: [{
    type: "Image",
    id: "logo-image",
    src: "/uploads/intentionally-missing-logo.png",
    width: 120,
    height: 120,
  }],
  metadata: {},
};

export function SafeLoadEditor() {
  const elementRef = useRef<HTMLCanvasElement>(null);
  const [canvas, setCanvas] = useState<Canvas | null>(null);
  const [problem, setProblem] = useState<DocumentEngineError | null>(null);
  const [message, setMessage] = useState("");
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    if (!elementRef.current) return;
    const created = new Canvas(elementRef.current, { width: 800, height: 500 });
    setCanvas(created);
    return () => { void created.dispose().catch(() => undefined); };
  }, []);

  const engine = useDocumentEngine(canvas, {
    storage,
    document: { id: "working-design" },
  });
  const state = useDocumentState(engine);

  async function run(action: () => Promise<unknown>, success: string) {
    setBusy(true);
    setProblem(null);
    setMessage("");
    try {
      await action();
      setMessage(success);
    } catch (error) {
      if (isDocumentEngineError(error)) {
        if (error.code === "LOAD_ABORTED") return;
        setProblem(error);
      } else {
        setMessage(error instanceof Error ? error.message : "Opening failed.");
      }
    } finally {
      setBusy(false);
    }
  }

  const disabled = !engine || busy || Boolean(state?.isSaving || state?.isLoading);

  return (
    <section>
      <button disabled={disabled} onClick={() => canvas?.add(
        new Textbox("Keep the current drawing", { left: 40, top: 40 })
      )}>Add text</button>
      <button disabled={disabled} onClick={() => {
        if (engine) void run(() => engine.save(), "Current drawing saved.");
      }}>Save current drawing</button>
      <button disabled={disabled} onClick={() => {
        if (engine) void run(
          () => engine.loadDocument(brokenDocument),
          "Document opened."
        );
      }}>Try opening the broken document</button>

      {problem?.code === "MISSING_ASSETS" ? (
        <div role="alert">
          <p>The document could not open. These images are unavailable:</p>
          <ul>
            {problem.missingAssets.map((asset) => (
              <li key={asset.url}>
                <code>{asset.url}</code>
                {" — Objects: "}{asset.objectIds.join(", ")}
              </li>
            ))}
          </ul>
          <p>Your current drawing is still visible.</p>
        </div>
      ) : problem?.code === "UNSAVED_CHANGES" ? (
        <p role="alert">Save the current drawing before opening another document.</p>
      ) : problem ? (
        <p role="alert">{problem.code}: {problem.message}</p>
      ) : null}

      <p role="status">{message}</p>
      <p aria-live="polite">{state?.saveStatus ?? "Preparing editor"}</p>
      <canvas ref={elementRef} aria-label="Drawing preserved after a failed load" />
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Clicking Try opening the broken document before saving an edit should show UNSAVED_CHANGES. After saving, the same operation reaches image preflight and should show MISSING_ASSETS. The app never passes discardUnsavedChanges: true automatically.

For an API-backed editor, replace the memory adapter with your HTTP adapter and call engine.load(documentId) inside the same handler. The companion guide, Save Fabric.js Canvas JSON to an API and Open It by Document ID, includes the adapter and app-owned endpoint.

The React guide, React hooks reference, and error code reference document the hooks and error fields used here.

Should you catch errors or subscribe to load:error?

Use the operation's catch when the result belongs to a particular button, route, or document selection. Use load:error when a shared panel or logging system needs to observe engine loading failures.

import { useDocumentEvent } from "fabricjs-document-engine/react";

// Inside a React component that already has an engine.
useDocumentEvent(engine, "load:error", ({ error }) => {
  if (error.code === "LOAD_ABORTED") return;
  console.error("Document loading failed", error.code, error);
});
Enter fullscreen mode Exit fullscreen mode

The event observes an error; it does not consume the promise rejection. Still handle the promise returned by engine.load() or engine.loadDocument(). If both paths show notifications, one failure can produce duplicate messages.

An unsaved-change guard can reject before the loading sequence starts. Catching the requested operation also covers that case. useDocumentState(engine) exposes loadError for failures tracked by the state store, but a persistent guard message should not depend only on that field.

The event payload is { error }, not the error directly. See the events API for the exact contract.

How do you repair a missing image without removing the object?

Use assets.replaceMissingImage when your app has a known replacement. The hook receives the missing URL and the object IDs that refer to it.

const engine = createDocumentEngine({
  canvas,
  storage,
  assets: {
    replaceMissingImage(image) {
      if (image.url === "/uploads/deleted-logo.png") {
        return "/assets/replacement-logo.png";
      }
      return null;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

This is an engine-options fragment; canvas and storage are the instances already owned by your app, and createDocumentEngine is imported from the package.

The engine checks the replacement URL too. If the replacement is also unavailable, the load still fails. If the replacement works, loading can continue and the engine reports an IMAGE_REPLACED warning.

A replacement policy changes document content. Make that policy clear to the user. For client artwork, returning null and asking for a replacement may be preferable to inserting a generic placeholder.

Save deliberately after a repaired load if the new address should be retained. A successful load begins a saved session; do not assume the replacement alone has marked the document dirty or written a new record to your backend.

See the asset options guide and createDocumentEngine API. engine.replaceImage(oldUrl, newUrl) repairs images already present in the current canvas; it does not modify an incoming document that preflight rejected.

What if the image URL expires instead of disappearing?

Resolve the stored asset address into a currently accessible URL with assets.resolveUrl. Your app can use that handler to request a fresh signed URL from its backend before the engine checks and loads the image.

const assets = {
  async resolveUrl(url: string) {
    if (!url.startsWith("asset://")) return url;
    const assetId = url.slice("asset://".length);
    const response = await fetch(
      `/api/assets/${encodeURIComponent(assetId)}/url`
    );
    if (!response.ok) throw new Error("The asset address could not be resolved.");
    const result = (await response.json()) as { url: string };
    return result.url;
  },
};
Enter fullscreen mode Exit fullscreen mode

The asset-resolution endpoint is app-owned backend code. The package does not implement asset permissions or signed-URL generation.

Also plan the return path when saving. The engine rewrites the incoming image address to the resolved URL; saving that content can persist the signed URL. Keep durable asset identity in app-managed data and normalize addresses in your storage layer if your API requires stable identifiers.

For tab-only blob: images, configure assets.upload while saving so reopening does not depend on the previous tab. Both hooks are described in the images and fonts guide.

Can you fix an image CORS failure from React alone?

The image host must permit the cross-origin request when the image is loaded with CORS enabled. Setting crossOrigin: "anonymous" in React or Fabric.js does not create permission on the remote server.

Check both the image request and response headers. For a canvas that displays correctly but cannot export, inspect whether the image was loaded in a way that tainted the canvas.

The engine's IMAGE_CROSS_ORIGIN warning helps identify risky image references. Export preflight can reject a raster export with EXPORT_BLOCKED; that is a different error from loading a document with MISSING_ASSETS.

Read the CORS guidance and export guide. The engine reports the problem; your app and image host control how the asset is served.

What should the editor do with each loading error?

Use error codes to decide what action to offer. Do not match error-message wording.

Engine code Useful editor response
MISSING_ASSETS List missing image URLs and affected object IDs; offer repair or retry
MISSING_FONTS Load the required fonts or explain why the document cannot open
UNKNOWN_OBJECT_TYPE Register the missing custom class before retrying
INVALID_DOCUMENT Show document validation details; request a valid saved record
UNSAVED_CHANGES Offer save, cancel, or an explicit discard decision
DOCUMENT_NOT_FOUND Explain that the requested stored document is unavailable
LOAD_ABORTED Usually keep quiet when a newer document selection superseded the request
LOAD_FAILED Inspect the storage or Fabric.js failure and the underlying cause

Missing fonts normally produce FONT_UNAVAILABLE warnings and fallback text. Set assets.requireFonts: true when the document must not open with fallback fonts; unavailable fonts then produce MISSING_FONTS.

The package's error code reference and custom object guide describe these cases.

Will awaiting loadFromJSON solve every loading problem?

Awaiting the load is necessary for code that depends on loaded objects. It does not make deleted images available or register a missing custom class.

For a direct Fabric.js load, a basic successful path is:

await canvas.loadFromJSON(savedJson);
canvas.requestRenderAll();
Enter fullscreen mode Exit fullscreen mode

That fixes the timing of rendering after the promise settles. With Fabric.js versions that permit failed objects to be omitted, a fulfilled promise alone does not prove every expected object was restored.

When using the document engine, wait for its load method. The engine requests rendering after its successful load.

How do you keep an older load from replacing a newer document?

Use engine.load(id) for the complete storage-and-loading operation. The engine claims the load before reading storage, so a slower response for an older selection cannot replace a newer completed load. A superseded request rejects with LOAD_ABORTED.

If your app separately fetches JSON and then calls loadDocument, your app must also prevent older fetch responses from being submitted after newer ones. The engine cannot know that a later loadDocument call came from an earlier UI selection.

The loading API documents the entry points, and the engine source shows the storage-response guard. Fabric.js itself also warns about overlapping loads in its loadFromJSON reference.

Start with one deliberately missing image. Confirm that the old drawing stays visible, the error contains the image URL and object IDs, and the React UI explains the failure. Then test expired URLs, missing fonts, and overlapping document selections against your own storage. The npm package, quick start, and GitHub repository provide the source and further examples.

Top comments (0)