Skip to main content

Large Format & HD Exports

Marketing posters, large-format prints, and print-ready files often run into this: "The 1920×1080 preview looks fine, but the client wants a 5m × 3m deliverable at 300 DPI." The bottleneck in these cases isn't Ydesign itself — it's the browser canvas size limit. This page covers:

  • How big a canvas the browser can actually paint
  • The real limits of Ydesign's client-side export (store.toDataURL / toBlob / saveAsImage)
  • How canvas size, multiplier, and dpi work together
  • When to switch to server-side rendering — either the Cloud Render API (hosted) or self-hosted @ydesign/node (planned)
  • The "preview-small, export-large" asset swapping strategy

Why you can't scale forever

In one line: every side of a browser canvas has a hard limit. Go past it and rendering fails, or the tab crashes.

BrowserPer-side limitNotes
Chrome65535 px, but total area ≤ 2^28 (~268M px²)Safe practical limit ≈ 16384 px
SafariiOS < 16384 px, macOS a bit more lenientMobile is very sensitive, often breaks under 8192
FirefoxAround 11180×11180Over limit → returns a transparent image

When you exceed the limit you typically see:

  • store.toDataURL() returns a fully transparent image, or only paints the top-left corner
  • Whole tab crashes (Aw, Snap!)
  • Console shows Failed to execute 'toDataURL' on 'HTMLCanvasElement'
  • Mobile browsers kill the process outright

💡 This isn't a Ydesign limitation — it's a hard physical ceiling set by the browser / OS / GPU. You'll hit it with any front-end canvas framework.

Ydesign's safe zone for client-side export

Empirical values:

EnvironmentSafe side limitRecommended usage
Desktop Chrome12000 pxmultiplier: 2 ~ 4
Desktop Safari10000 pxmultiplier: 2 ~ 3
Mobile4096 pxPrefer the cloud
Low-end devices2048 pxMust go cloud

As long as your longest output side ≤ 8000 px, most modern desktop browsers work reliably. Past that line, you should start thinking about server-side.


Three numbers, one relationship

When you use Ydesign you'll run into three numbers at once. Knowing which does what matters:

NameBelongs toControls
Canvas sizestore.width / store.heightThe "design size" you see in the editor, in pixels
multiplierExport option (ExportOptions)How many times bigger the output is than the canvas → decides output pixels
dpistore.dpi (via store.setUnit)Used only for ruler / unit-input conversion. Does not affect export pixels.

Formulas

output pixels (px) = canvas pixels × multiplier
physical size (mm) = canvas pixels / dpi × 25.4
effective DPI = multiplier × (canvas pixels / physical size × 25.4)

Example: A4 @ 300 DPI for print

A4 is 210 × 297 mm. At 300 DPI that's 2480 × 3508 pixels.

Option 1: Canvas uses real print pixels (recommended for print jobs)

// Use real 300dpi pixels directly
store.setSize({ width: 2480, height: 3508 });
store.setUnit({ unit: 'mm', dpi: 300 });

// multiplier=1 is already print-grade 300 DPI
await store.saveAsImage({
multiplier: 1,
format: 'png',
fileName: 'a4-print.png',
});

✅ Pros: Rulers, element positions are 1:1 with the print ⚠️ Cost: Larger canvas → slower rendering, bigger JSON

Option 2: 72dpi canvas + multiplier upscaling

// Lightweight canvas at screen dpi
store.setSize({ width: 595, height: 842 }); // A4 @ 72dpi
store.setUnit({ unit: 'mm', dpi: 72 });

// Upscale 4× at export → 2380×3368 ≈ 288 dpi
await store.saveAsImage({
multiplier: 4,
format: 'png',
});

✅ Pros: Small canvas, snappy editor ⚠️ Cost: Small type and thin strokes can drift 1-2px when scaled

How to choose? If print is a primary use case (covers, packaging, posters), use Option 1. If your product is mostly screen preview with occasional print exports, Option 2 works fine.


Four ways to handle large canvases

Before writing code, pick a lane using this table:

ScenarioRecommended approachRuns on
Longest side ≤ 8000 px, desktop users👉 Approach A: scale up the canvasClient
Longest side ≤ 12000 px, smoother editing👉 Approach B: scaled editing + multiplierClient
Want server-side fast, no ops👉 Approach C: Cloud Render APIServer (Ydesign hosted)
> 12000 px, data-sensitive, internal net👉 Approach D: self-host @ydesign/nodeServer (self-hosted)
Mobile / low-end devices👉 Approach C or D (server)Server
Unknown device capabilities👉 Hybrid: auto-pick strategyClient + Server

💡 Both server-side paths (C and D) share the same rendering core. That means you can start with C for a quick launch and smoothly switch to D later when data sensitivity or cost becomes a concern — the output is pixel-identical.


Approach A: scale up the canvas (≤ 8000px per side)

The most direct option. Suitable for banners and single-page posters that aren't too extreme.

// 1.5m × 1m @ 150dpi banner
// 1500 / 25.4 * 150 ≈ 8858 px → a bit over the limit
// Drop to 120dpi: 1500 / 25.4 * 120 ≈ 7087 px ✅
const targetDPI = 120;
const widthMM = 1500;
const heightMM = 1000;

const widthPx = Math.round((widthMM / 25.4) * targetDPI);
const heightPx = Math.round((heightMM / 25.4) * targetDPI);

store.setSize({ width: widthPx, height: heightPx });
store.setUnit({ unit: 'mm', dpi: targetDPI });

Fits: Safe range (≤ 8000px), desktop users Doesn't fit: Mobile, very large formats

Approach B: scaled editing + multiplier (≤ 8000px output)

Edit with a small, responsive canvas; scale up at export time.

// Target: 3m × 2m @ 200dpi outdoor print
// Real output pixels: 23622 × 15748 (way over the browser ceiling!)
// Use 1/4 scale canvas: 5906 × 3937 (safe zone)
const scale = 0.25;
const targetDPI = 200;
const widthMM = 3000;
const heightMM = 2000;

store.setSize({
width: Math.round((widthMM / 25.4) * targetDPI * scale),
height: Math.round((heightMM / 25.4) * targetDPI * scale),
});
// Keep the ruler showing the real physical size: 3000mm × 2000mm
store.setUnit({ unit: 'mm', dpi: targetDPI * scale });

// Export: multiplier = 1/scale produces the real target pixels
await store.saveAsImage({ multiplier: 1 / scale }); // → 23622 × 15748

Fits: Real pixels ≤ browser ceiling (roughly 160M pixels) Doesn't fit: Mobile, side > 12000 px

Approach C: Cloud Render API (hosted, no ceiling)

When you're past the browser limit, or want rendering off users' devices without running your own infra, use the Cloud Render API:

const res = await fetch('https://api.ydesign.com/api/render/image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer YOUR_API_KEY',
},
body: JSON.stringify({
json: store.toJSON(),
format: 'png',
multiplier: 8, // Cloud supports up to 8×, client typically ≤ 4
fonts: store.fonts.map(f => ({
fontFamily: f.fontFamily,
url: f.url,
})),
}),
});

const { url } = await res.json();
window.open(url, '_blank');

Characteristics:

  • ☁️ Hosted service, 5 minutes to integrate — just grab an API key
  • 🚀 Zero infrastructure, elastic scaling, maintained by Ydesign
  • 💰 Per-call billing (enterprise plan), see pricing
  • ⚠️ JSON is uploaded to Ydesign servers. Evaluate for data-sensitive scenarios.

Fits:

  • Real output > 12000 px per side
  • Mobile / low-end device users
  • Batch generation (1000 variant images)
  • Background cron / message queue consumers
  • Quick-launch, no-ops priority

Full reference: Cloud Render API.

Approach D: self-hosted @ydesign/node (planned)

If you don't want to rely on an external cloud service (data sensitivity, existing internal rendering cluster, cost pressure, air-gapped deployment), use our upcoming standalone Node package:

# 🚧 Planned — not yet published
pnpm add @ydesign/node
// Planned API design (see node-render.md)
import { createRenderer } from '@ydesign/node';

const renderer = await createRenderer({ concurrency: 5 });

const png = await renderer.jsonToImage(store.toJSON(), {
format: 'png',
multiplier: 8,
fonts: [
{ fontFamily: 'AlibabaPuHuiTi_2_115_Black', url: 'https://cdn/.../.ttf' },
],
});

// Vector PDF: resolution-independent, 3m × 5m prints in seconds
const pdf = await renderer.jsonToPDF(store.toJSON(), { vector: true });

await renderer.close();

Characteristics:

  • 🏠 Runs entirely on your own server, data never leaves your network
  • 🧩 Same engine as the Cloud Render API — pixel-identical output, smooth migration
  • 🎨 Supports jsonToImage / jsonToPDF (including vector PDF)
  • 💪 You handle deployment and ops (Docker image + Helm Chart will ship together)

Fits:

  • Data-sensitive (design JSON can't be sent to an external API)
  • Existing internal Node clusters, drop it in directly
  • Cost-sensitive at high call volumes (you only pay for your own servers)
  • Air-gapped / on-prem deployments

Approach C vs D at a glance

Dimension☁️ C: Cloud Render API🏠 D: @ydesign/node (planned)
DeploymentYdesign hostedYour own Node service
Onboarding5 minutes for API keySet up env + production deploy
Data leaves netYes, JSON uploadedNo, stays internal
Rendering coreSameSame
Vector PDFYesYes
Scale-outElastic, by planYou scale it
Cost modelPer-call billingJust your server costs
Best fitFast launch, no opsData sensitive, internal, cost

📖 Full deployment guide: Server-side image generation (Node.js) — complete with API reference, font handling, Docker deployment, concurrency strategies, BullMQ queues, and more.

Progress: The rendering core is done (it's what powers the Cloud Render API today). We're splitting it into a publishable standalone package. Follow progress via GitHub Discussions or the roadmap.

Recommended migration path: Launch with C quickly → move to D once you're sure you need self-hosting.


Auto-pick strategy (hybrid)

In a real product you often don't know the user's device — could be an M3 MacBook Pro, could be a mid-range Android. And you may run both "cloud" and "self-hosted" server paths. Factor it out:

// packages/editor/src/utils/export-strategy.ts
import { isMobile } from './screen';

/**
* Render destination:
* - 'client' → client-side export (store.saveAsImage)
* - 'cloud' → Ydesign Cloud Render API (hosted)
* - 'self-hosted' → your own @ydesign/node service
*/
type Strategy = 'client' | 'cloud' | 'self-hosted';

interface PickStrategyOptions {
/** Canvas width */
canvasWidth: number;
/** Canvas height */
canvasHeight: number;
/** Export multiplier */
multiplier: number;
/**
* Server preference:
* - 'cloud' (default) → use cloud when self-hosted isn't available
* - 'self-hosted' → prefer self-hosted when available
*/
serverPreference?: 'cloud' | 'self-hosted';
}

export function pickExportStrategy({
canvasWidth,
canvasHeight,
multiplier,
serverPreference = 'cloud',
}: PickStrategyOptions): Strategy {
const maxSide = Math.max(canvasWidth, canvasHeight) * multiplier;

// Mobile is very unfriendly to large canvases → always server
if (isMobile()) return serverPreference;

// @ts-expect-error navigator.deviceMemory isn't in the standard types but most browsers support it
const lowMemory = (navigator.deviceMemory ?? 8) < 4;
if (lowMemory) return serverPreference;

// Desktop ≤ 8000px goes client, else server
return maxSide <= 8000 ? 'client' : serverPreference;
}

Then at the business layer:

import { observer } from 'mobx-react-lite';
import { Button, message } from 'antd';
import { pickExportStrategy } from './utils/export-strategy';

export const ExportButton = observer(({ store }) => {
const exportAt = async (multiplier: number) => {
const strategy = pickExportStrategy({
canvasWidth: store.width,
canvasHeight: store.height,
multiplier,
// If you've deployed @ydesign/node → set to 'self-hosted'
// Otherwise keep the default 'cloud'
serverPreference: 'cloud',
});

const key = 'export';
message.loading({ content: 'Exporting…', key });

const payload = {
json: store.toJSON(),
format: 'png',
multiplier,
fonts: store.fonts.map(f => ({ fontFamily: f.fontFamily, url: f.url })),
};

let url: string;
if (strategy === 'client') {
await store.saveAsImage({ multiplier, format: 'png' });
message.success({ content: 'Exported', key });
return;
} else if (strategy === 'cloud') {
// Approach C: Ydesign Cloud Render
const res = await fetch('https://api.ydesign.com/api/render/image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_YDESIGN_KEY}`,
},
body: JSON.stringify(payload),
});
({ url } = await res.json());
} else {
// Approach D: self-hosted @ydesign/node
const res = await fetch('/api/internal/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
({ url } = await res.json());
}

window.open(url, '_blank');
message.success({ content: 'Rendered', key });
};

return <Button onClick={() => exportAt(4)}>Export 4× HD</Button>;
});

💡 If you deploy both (cloud as fallback, self-hosted as primary), you can build a three-tier fallback: self-hosted → cloud → client. Gives users the best success rate.


Asset swapping: edit with small, export with large

This is the single most important optimization when doing large-format exports.

Scenario: a user uploads an 8000×6000 original but it only occupies a small area on the A4 canvas. Loading the original in the editor causes:

  • Memory explosion (180MB per image once decoded)
  • Dragging lag
  • Huge JSON with base64 payloads

The standard practice: edit with a preview image (800px), swap to original just before export.

1. Keep both URLs at upload time

setUploadFunc (see custom image upload) lets you return extended fields alongside the image. Store the high-resolution URL on the object's keyValues:

editor.setUploadFunc(async file => {
// Upload the original
const originalUrl = await uploadToOSS(file);
// Generate an 800px preview
const previewUrl = await generateThumb(file, 800);

return {
url: previewUrl, // What the editor loads
keyValues: {
hdSrc: originalUrl, // Remember the HD location
},
};
});

💡 keyValues is Ydesign's custom-field layer on top of Fabric objects. It survives toJSON and won't get lost.

2. Swap just before export

async function exportHD(store, multiplier = 4) {
// Collect images that need swapping
const images = store.editor!.customCanvas.canvas.getObjects()
.filter(o => o.type === 'image' && (o as any).keyValues?.hdSrc) as FabricImage[];

// Remember originals, swap in HD versions
const originals: { img: FabricImage; src: string }[] = [];
await Promise.all(
images.map(async img => {
originals.push({ img, src: img.getSrc() });
await img.setSrc((img as any).keyValues.hdSrc, { crossOrigin: 'anonymous' });
}),
);
store.editor!.customCanvas.canvas.requestRenderAll();

try {
await store.saveAsImage({ multiplier, format: 'png' });
} finally {
// Always restore previews so the editor stays snappy
await Promise.all(
originals.map(({ img, src }) => img.setSrc(src, { crossOrigin: 'anonymous' })),
);
store.editor!.customCanvas.canvas.requestRenderAll();
}
}

3. Validate resolution up front

Let the operations team / users know early: "This image will look blurry at 300 DPI":

/**
* Check whether an image is sharp enough for the target print size.
*/
export async function validateImageResolution(
src: string,
widthMM: number,
heightMM: number,
targetDPI = 300,
): Promise<{ ok: boolean; actual: [number, number]; required: [number, number] }> {
const requiredW = Math.round((widthMM / 25.4) * targetDPI);
const requiredH = Math.round((heightMM / 25.4) * targetDPI);

const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.crossOrigin = 'anonymous';
i.onload = () => resolve(i);
i.onerror = reject;
i.src = src;
});

return {
ok: img.naturalWidth >= requiredW && img.naturalHeight >= requiredH,
actual: [img.naturalWidth, img.naturalHeight],
required: [requiredW, requiredH],
};
}

Use in product code:

const r = await validateImageResolution(file.src, 210, 297, 300);
if (!r.ok) {
Modal.warn({
title: 'Image resolution too low',
content: `Currently ${r.actual[0]}×${r.actual[1]}. Printing A4 @ 300dpi needs at least ${r.required[0]}×${r.required[1]}`,
});
}

Best practices

Editor performance:

  • ✅ Always load previews in the editor; keep HD URLs on keyValues.hdSrc
  • ✅ Force thumbnail generation for images larger than 5MB
  • Configure CORS and serve HD assets via CDN

Export strategy:

  • ✅ Use pickExportStrategy to auto-route by device and output size
  • ✅ For print materials prefer "canvas at real pixels + multiplier: 1"
  • ✅ Anything over 8000px per side goes straight to server-side (cloud or self-hosted)
  • ✅ Batch / background jobs always go server-side
  • ✅ Data-sensitive or private deployments prefer self-hosted @ydesign/node
  • ✅ Quick launch with no ops → Cloud Render API

Asset management:

  • ✅ Validate image resolution at upload time against the current canvas print needs
  • ✅ Fonts and images must be fetchable by the server (public access + CORS open)
  • ✅ Cloud Render URLs only live for 24 hours — copy to your own OSS immediately

Troubleshooting

Q1: The exported image is all white or transparent

Almost always: canvas exceeds the browser ceiling.

Diagnose: The console will show a warning, or toDataURL returns an abnormal base64 (not starting with the usual data:image/png;base64,iVBORw).

Fix:

  • Lower the multiplier, or
  • Shrink store.width / height, or
  • Go cloud

Q2: Mobile Safari freezes after export

Mobile canvas memory is much tighter than desktop. Route aggressively:

if (isMobile()) {
// Force cloud on mobile
return await exportViaCloud(store, multiplier);
}

Q3: Print shop says the fonts look blurry at the edges

Usually multiplier is too low. Print needs at least 300 DPI, meaning:

multiplier >= 300 / canvas-effective-dpi

If your canvas is a 72dpi design and print needs 300 DPI, use at least multiplier: 4.2.

Q4: multiplier: 8 crashes the browser

Client-side export is capped by browser memory. Empirically 4× is the stable ceiling for most desktop devices. For higher, use the Cloud Render API (supports up to 8×).

Q5: Cloud-rendered output doesn't match the client preview

Check two things:

  1. Were fonts sent? The fonts array must cover every non-system font. See fonts guide.
  2. Can the server reach your images? No login-gated URLs. See CORS guide.

Further reading