Skip to main content

Server-side Image Generation (Node.js)

๐Ÿšง The @ydesign/node package described here is planned and not yet published. The code below follows our confirmed API design draft. Once released, the real interface will be consistent with this.

If you need to ship today, use the Cloud Render API first โ€” the underlying engine is identical to @ydesign/node, so you can migrate later with no code rewrites.


Two ways to do server-side renderingโ€‹

Ydesign offers two paths. Pick based on your needs:

Dimensionโ˜๏ธ Cloud Render API๐Ÿ  @ydesign/node (this doc)
DeploymentYdesign-hosted, call an APIYour own Node service
Onboarding cost5 minutesSet up env + production deploy
Data leaves netJSON uploaded to YdesignNever leaves your network
Resource fetchingYdesign pulls for you (public URLs)Your server fetches directly
Rendering coreMaintained by YdesignSame engine
FontsDeclared per requestLocal registration or remote
ConcurrencyElastic, by planYou scale it
Cost modelPer-call billingJust your own server costs
Best fitFast launch, no ops, batch marketingData sensitive, internal, cost

๐Ÿ’ก Same rendering core: @ydesign/node is our Cloud Render service extracted into a publishable package. Which means:

  • Cloud Render and @ydesign/node produce pixel-identical output
  • Launch with Cloud Render first, move to self-hosted later โ€” no JSON / font changes needed

Installation (planned)โ€‹

# Not yet published
pnpm add @ydesign/node

# First run will download Chromium (~150 MB)
# Machines that already have Chrome can skip โ€” see "Custom executable path"

Requirements:

  • Node.js โ‰ฅ 18
  • Linux / macOS / Windows all supported
  • At least 1 GB RAM per instance (4 GB+ recommended for high concurrency)

โš ๏ธ Built on Puppeteer / headless Chromium (not node-canvas). This is a deliberate trade-off โ€” a real browser engine guarantees output identical to the client side, avoiding the various node-canvas quirks around Fabric features.


Minimal exampleโ€‹

import fs from 'node:fs/promises';
import { createRenderer } from '@ydesign/node';

async function main() {
// 1) Create a renderer (launches a headless browser)
const renderer = await createRenderer();

// 2) Read a JSON produced by store.toJSON()
const json = JSON.parse(await fs.readFile('./design.json', 'utf-8'));

// 3) Render as PNG (default)
const png = await renderer.jsonToImage(json);
await fs.writeFile('./out.png', png);

// 4) Render as JPEG (smaller)
const jpeg = await renderer.jsonToImage(json, {
format: 'jpeg',
quality: 0.9,
multiplier: 2,
});
await fs.writeFile('./out.jpg', jpeg);

// 5) Always close to release the browser process
await renderer.close();
}

main();

That's it. Whether the JSON came from store.toJSON() on the front-end or from your template database, it works the same.


API referenceโ€‹

createRenderer(options?)โ€‹

Launches a renderer instance (= a headless Chromium process). It's expensive โ€” reuse one, don't new on every request.

const renderer = await createRenderer({
/** Reuse an installed Chrome (skip Puppeteer's bundled Chromium) */
executablePath: '/usr/bin/google-chrome',

/** Extra Chromium launch args */
args: ['--no-sandbox', '--disable-dev-shm-usage'],

/** Concurrent pages (internal pool), default 3 */
concurrency: 5,

/** Per-render timeout, default 30000ms */
timeout: 30_000,

/** Custom logger */
logger: console,
});

renderer.jsonToImage(json, options?)โ€‹

Renders to an image. Returns a Buffer.

interface RenderImageOptions {
/** Output format, default 'png' */
format?: 'png' | 'jpeg' | 'webp';

/** JPEG / WebP quality, 0-1, default 1 */
quality?: number;

/** Multiplier (HD export), default 1. No browser ceiling here. */
multiplier?: number;

/** Custom fonts, same shape as store.fonts */
fonts?: Array<{ fontFamily: string; url: string }>;

/** Proxy for image / font fetches */
proxy?: string;

/** Crop region */
crop?: { left: number; top: number; width: number; height: number };

/** Filter objects (return false to skip), e.g. removing watermarks */
filter?: (obj: { type: string; name?: string; [k: string]: any }) => boolean;
}

renderer.jsonToPDF(json, options?) (planned ยท vector-first)โ€‹

const pdf = await renderer.jsonToPDF(json, {
vector: true, // Vector mode: text/shapes stay vector, resolution-independent
pageSize: 'A4', // Or { width: 1920, height: 1080 }
fonts: [ /* ... */ ],
});

The biggest win of vector PDF: bypass the browser canvas pixel ceiling. A 3m ร— 5m outdoor print can come out in seconds. See the previous article Large Format & HD Exports.

renderer.close()โ€‹

Closes the browser process and releases all resources. Always call this before exiting the process, otherwise you'll leave zombie Chromium processes behind.


Font handlingโ€‹

The Node side has no access to "user local fonts", so every non-system font must be declared explicitly. The structure is identical to the Cloud Render API's fonts field.

const png = await renderer.jsonToImage(json, {
fonts: [
{
fontFamily: 'AlibabaPuHuiTi_2_115_Black',
url: 'https://your-cdn.com/fonts/AlibabaPuHuiTi_2_115_Black.ttf',
},
// โš ๏ธ If the design uses Bold, you must declare the Bold file separately
{
fontFamily: 'AlibabaPuHuiTi_2_115_Black',
url: 'https://your-cdn.com/fonts/AlibabaPuHuiTi_2_115_Bold.ttf',
},
],
});

If your front-end uses the recommended addGlobalFont({ styles }) style, flatten them on the server like so:

// Flatten store.fonts into the shape @ydesign/node expects
function flattenFonts(fonts: Array<any>) {
return fonts.flatMap(f =>
(f.styles || [{ src: f.url }]).map((s: any) => ({
fontFamily: f.fontFamily,
url: (s.src || s.url || '').replace(/^url\(|\)$|['"]/g, ''),
})),
);
}

const png = await renderer.jsonToImage(json, {
fonts: flattenFonts(designJson.fonts || []),
});

Option B: register local font files up frontโ€‹

Works well for on-prem deployments where the fonts live on the server disk:

import path from 'node:path';

const renderer = await createRenderer();

// Pre-register, available to every subsequent jsonToImage call
await renderer.registerFonts([
{ fontFamily: 'FiraSans', path: path.resolve('./fonts/FiraSans-Regular.ttf') },
{ fontFamily: 'FiraSans', path: path.resolve('./fonts/FiraSans-Bold.ttf') },
{ fontFamily: 'NotoSansSC', path: path.resolve('./fonts/NotoSansSC.otf') },
]);

const png = await renderer.jsonToImage(json);

Option C: proxy + CDN (batch rendering)โ€‹

Lots of designs, many font URLs, but the same set across renders:

const renderer = await createRenderer({
// Force traffic through your internal cache
proxy: 'http://cache.internal:3128',
});

Fonts fetched from the origin on first render are cached by the proxy. Subsequent renders hit the cache and run much faster.

๐Ÿ“– The complete font troubleshooting checklist lives in Font consistency.


Image handlingโ€‹

Remote image fetchingโ€‹

Same as fonts โ€” objects[].src URLs are fetched directly by the server:

  • โœ… Must be publicly reachable (Ydesign's IPs reach them in the cloud case)
  • โœ… Configure sensible CORS (not strictly required for self-hosted, but internal proxies may enforce it)
  • โŒ No login-token URLs
const renderer = await createRenderer({
// Same proxy applies to image and font fetches
proxy: 'http://your-proxy:8080',

// Image fetch timeout, default 10s
resourceTimeout: 15_000,
});

Base64 imagesโ€‹

If the JSON contains data:image/... base64, Chromium decodes it inline without network. Fast, but JSON gets huge โ€” not recommended as a default.

Private OSS / signed URLsโ€‹

Signed URLs are common. Pass auth headers:

const renderer = await createRenderer({
// Extra headers applied to every fetch (server-auth scenarios)
extraHeaders: {
Authorization: `Bearer ${process.env.INTERNAL_TOKEN}`,
},
});

Production deploymentโ€‹

1. Reuse the renderer instance (critical)โ€‹

Anti-pattern: createRenderer() โ†’ close() per HTTP request. Starting Chromium takes 1-2 seconds; your API will never be fast.

Correct pattern: Create one renderer at process start, close it on graceful shutdown:

// renderer.ts
import { createRenderer } from '@ydesign/node';

export const renderer = await createRenderer({
concurrency: 5, // Internal page pool, 5 concurrent renders
});

// Clean up on shutdown
process.on('SIGTERM', async () => {
await renderer.close();
process.exit(0);
});
// server.ts
import Fastify from 'fastify';
import { renderer } from './renderer';

const app = Fastify();

app.post('/render', async (req, reply) => {
const { json, multiplier = 1, fonts = [] } = req.body as any;
const png = await renderer.jsonToImage(json, {
format: 'png',
multiplier,
fonts,
});
reply.type('image/png').send(png);
});

app.listen({ port: 3000 });

2. Docker deploymentโ€‹

Chromium needs a bunch of runtime libs. This Dockerfile is a solid starting template:

FROM node:20-bookworm-slim

# Chromium runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 \
libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 \
libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 \
libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 \
libxss1 libxtst6 lsb-release wget xdg-utils \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --prod
COPY . .

# Sandbox must be off inside containers
ENV PUPPETEER_ARGS="--no-sandbox --disable-dev-shm-usage"

CMD ["node", "server.js"]

Matching code:

const renderer = await createRenderer({
args: (process.env.PUPPETEER_ARGS ?? '').split(' ').filter(Boolean),
});

3. Memory managementโ€‹

Headless Chromium rendering large canvases is memory-hungry. For production:

  • โœ… Give containers โ‰ฅ 2 GB of RAM (4 GB recommended for large exports)
  • โœ… --disable-dev-shm-usage is mandatory (containers default to a 64M /dev/shm)
  • โœ… Set up health checks so OOM pods restart automatically
  • โœ… Long-running services: periodically renderer.close() and rebuild to avoid Chromium memory leaks

4. Horizontal scalingโ€‹

Renderer instances hold no shared state, so multi-replica deployment is free:

# k8s deployment snippet
replicas: 3
resources:
requests: { memory: '2Gi', cpu: '500m' }
limits: { memory: '4Gi', cpu: '2' }

Put an nginx / k8s Service in front and load balance.

5. Health checksโ€‹

app.get('/health', async (_, reply) => {
try {
// Render a 100ร—100 blank to quickly confirm the rendering path is alive
await renderer.jsonToImage(
{ version: '6.0.0', width: 100, height: 100, objects: [] },
{ multiplier: 1 },
);
reply.send({ ok: true });
} catch (e) {
reply.code(503).send({ ok: false, error: String(e) });
}
});

Typical scenariosโ€‹

Scenario 1: Batch generate marketing images (one template + dynamic copy)โ€‹

import { renderer } from './renderer';

async function batchGenerate(template: any, titles: string[]) {
return Promise.all(
titles.map(async title => {
const json = structuredClone(template);
// Assume the 4th object is the main title
json.objects[3].text = title;

const png = await renderer.jsonToImage(json, {
format: 'jpeg',
quality: 0.88,
multiplier: 2,
fonts: /* shared font declarations */,
});
await uploadToOSS(png, `marketing/${title}.jpg`);
}),
);
}

await batchGenerate(template, ['Spring Sale', 'Summer Kickoff', 'Black Friday']);

Scenario 2: Async task queue (BullMQ)โ€‹

Large renders can take seconds to minutes โ€” bad fit for synchronous HTTP. Decouple via queue:

// worker.ts
import { Worker } from 'bullmq';
import { renderer } from './renderer';

new Worker(
'render',
async job => {
const { json, options } = job.data;
const buffer = await renderer.jsonToImage(json, options);
return await uploadToOSS(buffer, `renders/${job.id}.png`);
},
{
connection: { host: 'redis', port: 6379 },
concurrency: 3, // Match with renderer.concurrency
},
);

Scenario 3: Webhook callbackโ€‹

Notify the business side when a long job finishes:

app.post('/render-async', async (req, reply) => {
const { json, webhookUrl } = req.body as any;
const jobId = crypto.randomUUID();

// Return 202 immediately, render in background
reply.code(202).send({ jobId });

renderer
.jsonToImage(json)
.then(async buffer => {
const url = await uploadToOSS(buffer, `renders/${jobId}.png`);
await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify({ jobId, status: 'done', url }),
});
})
.catch(async e => {
await fetch(webhookUrl, {
method: 'POST',
body: JSON.stringify({ jobId, status: 'error', message: String(e) }),
});
});
});

Troubleshootingโ€‹

Q1: Container crashes on startโ€‹

90% of the time this is missing --no-sandbox. Containers don't have a full user namespace, so Chromium's sandbox exits on startup.

await createRenderer({
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});

Q2: Rendered text doesn't align rightโ€‹

Use the same approach as the client side: walk through the 6-step checklist in Font consistency ยท debugging checklist. Hits 90% of cases.

Q3: No image, just a broken iconโ€‹

Resource fetch failed. Common causes:

  • Image URL is 404 / 403
  • Server returns an HTML error page (Content-Type isn't image/*)
  • Firewall blocks outbound traffic from the server

Turn on the renderer's verbose logger to see request details:

const renderer = await createRenderer({
logger: {
info: console.log,
warn: console.warn,
error: console.error,
},
});

Q4: Multi-replica deployment re-downloads fonts every timeโ€‹

Put a proxy cache in front of the renderer, or use Option B (pre-register local fonts).

Q5: Production gets slower after a few hoursโ€‹

Chromium fragments memory over time. Simple fix:

  • Close and rebuild the renderer every N (e.g. 1000) jsonToImage calls
  • Or use K8s livenessProbe + memory.limit to auto-restart on OOM

Q6: Can I just use polotno-node for now?โ€‹

Technically yes (both use Fabric JSON), but:

  • Ydesign's extended fields like __strokeOptions (smart image stroke) and keyValues (custom fields) aren't recognized by polotno-node โ€” exported results will miss those effects
  • @ydesign/node's API matches Ydesign's client-side conventions, so future migration is free

Recommended path:

  1. Now: launch with the Cloud Render API (same engine, guaranteed consistency)
  2. Later: migrate to @ydesign/node once it ships

Further readingโ€‹


Roadmapโ€‹

Progress of @ydesign/node:

  • โœ… M1 ยท Core extraction: Separate the rendering core from the Cloud Render service (done โ€” it's what powers Cloud Render today)
  • ๐Ÿšง M2 ยท Package API: createRenderer / jsonToImage / jsonToPDF public API
  • โณ M3 ยท Vector PDF: vector: true mode
  • โณ M4 ยท Official Docker image + K8s Helm Chart
  • โณ M5 ยท NPM public release

Have a specific use case or want to join the beta? Join us on GitHub Discussions.