Server-side Image Generation (Node.js)
๐ง The
@ydesign/nodepackage 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) |
|---|---|---|
| Deployment | Ydesign-hosted, call an API | Your own Node service |
| Onboarding cost | 5 minutes | Set up env + production deploy |
| Data leaves net | JSON uploaded to Ydesign | Never leaves your network |
| Resource fetching | Ydesign pulls for you (public URLs) | Your server fetches directly |
| Rendering core | Maintained by Ydesign | Same engine |
| Fonts | Declared per request | Local registration or remote |
| Concurrency | Elastic, by plan | You scale it |
| Cost model | Per-call billing | Just your own server costs |
| Best fit | Fast launch, no ops, batch marketing | Data sensitive, internal, cost |
๐ก Same rendering core:
@ydesign/nodeis our Cloud Render service extracted into a publishable package. Which means:
- Cloud Render and
@ydesign/nodeproduce 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.
Option A: pass URLs via fonts (recommended)โ
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-usageis 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)
jsonToImagecalls - Or use K8s
livenessProbe+memory.limitto 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) andkeyValues(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:
- Now: launch with the Cloud Render API (same engine, guaranteed consistency)
- Later: migrate to
@ydesign/nodeonce it ships
Further readingโ
- ๐ Cloud Render API โ The hosted alternative when you don't want to self-host; same engine
- ๐ Large Format & HD Exports โ Full discussion of client vs server selection
- ๐ Font consistency โ Avoiding the server-side font pitfalls
- ๐ CORS โ CORS setup for fonts / images
- ๐ Scenes & import/export โ Where the client-side JSON comes from
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/jsonToPDFpublic API - โณ M3 ยท Vector PDF:
vector: truemode - โณ 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.