Making thumbnails for hundreds of saved designs usually means Fabric.js multiple canvases, one per design. The tab runs out of memory long before the end, because browsers give back a canvas's memory late, and a canvas dispose call in Fabric.js does not free it at all. renderDocuments uses a few reused canvases instead and frees everything after each document.
Loading the live demo. Its code is below.
"use client";
import { type FabricDocument, renderDocuments } from "fabricjs-document-engine";
import { CircleStopIcon, ImagesIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { COLORS, DemoFrame, Status, Toolbar, ToolButton } from "./demo-ui";
import { PAGE } from "./use-fabric-canvas";
const COUNT = 24;
/** A saved design, as a database would return it. */
function savedDesign(index: number): FabricDocument {
const color = (offset: number) => COLORS[(index + offset) % COLORS.length];
return {
schemaVersion: 1,
id: `design-${index + 1}`,
createdAt: "",
updatedAt: "",
canvas: { width: PAGE.width, height: PAGE.height, background: "#ffffff" },
objects: [
{
type: "Rect",
originX: "left",
originY: "top",
left: 40,
top: 40,
width: 400,
height: 220,
rx: 24,
ry: 24,
fill: color(0),
},
{
type: "Circle",
originX: "left",
originY: "top",
left: 70 + (index % 5) * 50,
top: 80,
radius: 60,
fill: color(2),
},
{
type: "Textbox",
originX: "left",
originY: "top",
left: 250,
top: 120,
width: 170,
text: `#${index + 1}`,
fontSize: 64,
fontFamily: "sans-serif",
fill: "#0d0d0d",
},
],
metadata: {},
};
}
export default function BatchRenderDemo() {
const t = useTranslations("demos");
const [thumbnails, setThumbnails] = useState<
Array<{ id: string; url: string }>
>([]);
const [done, setDone] = useState(0);
const [running, setRunning] = useState(false);
const [message, setMessage] = useState("");
const controller = useRef<AbortController | null>(null);
const urls = useRef<string[]>([]);
// Thumbnails are object URLs; free them when the demo goes away.
useEffect(
() => () => urls.current.forEach((url) => URL.revokeObjectURL(url)),
[]
);
async function render() {
urls.current.forEach((url) => URL.revokeObjectURL(url));
urls.current = [];
setThumbnails([]);
setDone(0);
setRunning(true);
const current = new AbortController();
controller.current = current;
const started = performance.now();
const designs = Array.from({ length: COUNT }, (_, index) =>
savedDesign(index)
);
try {
for await (const { documentId, result } of renderDocuments(designs, {
format: "png",
scale: 0.25,
concurrency: 2,
signal: current.signal,
onProgress: ({ done: finished }) => setDone(finished),
})) {
if (!result) continue;
const url = URL.createObjectURL(result.blob);
urls.current.push(url);
setThumbnails((previous) => [
...previous,
{ id: documentId ?? url, url },
]);
}
setMessage(
t("batch.done", {
count: COUNT,
ms: Math.round(performance.now() - started),
})
);
} catch {
setMessage(t("batch.cancelled"));
} finally {
controller.current = null;
setRunning(false);
}
}
return (
<DemoFrame>
<ul
aria-label={t("batch.list")}
className="grid aspect-480/300 grid-cols-6 content-start gap-1.5 overflow-hidden rounded-lg border bg-muted/40 p-2"
>
{thumbnails.map(({ id, url }) => (
<li key={id} className="overflow-hidden rounded-sm border bg-white">
{/* eslint-disable-next-line @next/next/no-img-element -- a rendered PNG from the batch */}
<img src={url} alt={id} className="block aspect-480/300 w-full" />
</li>
))}
</ul>
<Toolbar>
<ToolButton
icon={ImagesIcon}
label={t("batch.render", { count: COUNT })}
primary
disabled={running}
onClick={() => void render()}
/>
<ToolButton
icon={CircleStopIcon}
label={t("batch.cancel")}
disabled={!running}
onClick={() => controller.current?.abort()}
/>
</Toolbar>
<Status tone={running ? "busy" : message ? "ok" : "idle"}>
{running
? t("batch.progress", { done, total: COUNT })
: message || t("batch.hint")}
</Status>
</DemoFrame>
);
}Render thumbnails
import { renderDocuments } from "fabricjs-document-engine";
for await (const { documentId, result, error } of renderDocuments(savedDocuments, {
format: "png",
scale: 0.25,
concurrency: 2,
})) {
if (result) await uploadThumbnail(documentId, result.blob);
else console.warn(documentId, error?.message);
}- Results arrive as each document finishes, so each one can be uploaded and dropped instead of being held in memory.
- A broken document reports its own
error, and the rest still render. - Every
ExportOptionsfield works, so the same loop can make a canvas to image PNG, a JPEG, an SVG or a JSON export.
Documents from a database
documents can be any iterable, or an async iterable such as pages of a query, so the whole set never sits in memory at once:
async function* allDesigns() {
for (let page = 0; ; page += 1) {
const rows = await db.designs.findMany({ skip: page * 50, take: 50 });
if (rows.length === 0) return;
yield* rows.map((row) => row.document);
}
}
for await (const rendered of renderDocuments(allDesigns(), { format: "png", scale: 0.5 })) {
// store rendered.result
}Memory, cancelling and progress
| Option | Purpose | Default |
|---|---|---|
concurrency | How many documents render at once, each on its canvas | 2 |
onProgress | Called with { done, total } after each document | none |
signal | Stops the batch with EXPORT_ABORTED | none |
customObjects, assets, limits | As for createDocumentEngine | none |
- Fabric.js multiple canvases are avoided: only
concurrencycanvases exist, whatever the number of documents. - After each document, its objects, their cache canvases and the export canvas are released at once.
- Stopping early, with
breakor thesignal, frees every canvas too.
Rendering needs a browser. For PDF files with one page per document, pass the documents to PDF export. The function is listed with the helpers.