Skip to main content

Server-side Image Generation (Node.js)

🚧 Decision update (2026-07): There will be no standalone @ydesign/node package. Headless export lands in @ydesign/core/node (planned), sharing the same design JSON as the browser. If examples below still mention @ydesign/node, treat it as the old name: import { createRenderer } from '@ydesign/core/node'.

Need production today? Use the Cloud Render API first; migrate when @ydesign/core/node ships.


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.