Chart.js and ECharts to Image, Server Side
Render Chart.js and ECharts charts to PNG on the server for emails and PDFs: animation off, a ready signal, no-browser options and measured timings.
To turn a Chart.js or ECharts chart into an image on the server, do two things. First, turn animation off. Second, capture only after the chart says it has drawn. Everything else is a choice of where the chart runs: in headless Chrome, natively in Node, or through an API. This post measures all three for the same two charts, for the jobs where a static chart is the only option: emails, PDF reports and chat messages.
It belongs to our HTML to image series. If your problem is general CSS support rather than charts, read render HTML to PNG first.
Tested on 2026-10-18 with Node 22.18.0 on macOS 27.0.1 (Apple M1 Pro):
[email protected],[email protected],[email protected](Chromium headless shell),[email protected],@resvg/[email protected]. Both charts are 800 x 400: a Chart.js filled line of six monthly values and an ECharts bar chart of the same values. The RenderScreenshot request near the end follows the API reference but was not part of the timed run.
The animation problem, measured
Both libraries animate by default. A browser fires load long before the animation ends, so a screenshot "after load" catches a chart in motion. To put a number on it, we captured each chart at load with animation on, then again once the animation reported completion. We counted the coloured pixels in each image, meaning pixels whose RGB channels differ by more than 40, so white, grey and black do not count.
| Chart | Coloured pixels at load (animated) |
After animation finished | With animation off | Drawn at load |
|---|---|---|---|---|
| Chart.js line | 3,331 to 3,351 | 3,832 | 3,832 | 87% |
| ECharts bar | 0 | 66,916 | 66,916 | 0% |
The ECharts image at load had axes and labels but no bars. The Chart.js image looked almost right: the line was still rising from the baseline, so values were wrong in a way you might not notice in review. With animation off, both charts were complete on the first frame, and identical to the finished animated versions.
A fixed delay is the usual workaround, and it is the wrong one. A delay long enough for a slow CI machine wastes time on every render, and a delay that is too short ships wrong numbers. Turn animation off and wait for a signal instead.
Timings for each path
Median of three runs after a first warm-up run. "First" is the first run in a fresh process.
| Path | Chart | Time per chart | First run | PNG bytes |
|---|---|---|---|---|
| Playwright, page reused, animation off | Chart.js | 85 to 119 ms | 301 ms | 23,720 |
| Playwright, page reused, animation off | ECharts | 147 to 239 ms | 239 ms | 8,660 |
| Chart.js + skia-canvas, no browser | Chart.js | 101 to 129 ms | 636 ms | 17,730 |
| ECharts SSR to SVG + resvg (system fonts), no browser | ECharts | 468 to 493 ms | 2,548 ms | 11,090 (SVG 4,568) |
| ECharts SSR to SVG + resvg (one font file), no browser | ECharts | 11 to 15 ms | 32 ms | not recorded |
The Playwright times exclude browser start-up, which is a separate cost of roughly a second per process on this machine. The no-browser paths start faster and need no Chrome install. They pay for it in fidelity: there is no DOM, no CSS and no web fonts, only what the canvas or SVG backend draws. The ECharts SSR path looked slowest at first. When we timed its two steps separately, ECharts produced the SVG in 3 to 25 ms, and resvg spent 354 to 501 ms loading the system fonts on every call. With loadSystemFonts: false and one font file passed in, the PNG step took 7 to 11 ms. The second SSR row adds those two steps together. If the destination accepts SVG, skip resvg and send the 4.5 KB SVG.
Path 1: a chart page in headless Chrome
This path renders exactly what users see in your web app, including your fonts and CSS. Build a small HTML page per chart. The page sets data-ready="true" on <body> after the chart has drawn, and the renderer waits for that attribute.
// chart-page.mjs — HTML pages for one chart, with a ready signal. import { readFileSync } from 'node:fs'; const chartjs = readFileSync(new URL('./node_modules/chart.js/dist/chart.umd.min.js', import.meta.url), 'utf8'); const echartsJs = readFileSync(new URL('./node_modules/echarts/dist/echarts.min.js', import.meta.url), 'utf8'); // JSON inside <script>: escape "<" so a value such as "</script>" cannot end the tag. const safeJson = (v) => JSON.stringify(v).replaceAll('<', '\\u003c'); export function chartJsPage(config, { width = 800, height = 400 } = {}) { return `<!doctype html><html><head><meta charset="utf-8"> <style>html,body{margin:0;background:#fff}#wrap{width:${width}px;height:${height}px}</style> <script>${chartjs}</script></head><body> <div id="wrap"><canvas id="chart"></canvas></div> <script> { // block scope: page.setContent() keeps globals, so a top-level const would collide on reuse const config = ${safeJson(config)}; config.options = { ...config.options, responsive: true, maintainAspectRatio: false, animation: false }; new Chart(document.getElementById('chart'), config); requestAnimationFrame(() => { document.body.dataset.ready = 'true'; }); } </script></body></html>`; } export function echartsPage(option, { width = 800, height = 400 } = {}) { return `<!doctype html><html><head><meta charset="utf-8"> <style>html,body{margin:0;background:#fff}#chart{width:${width}px;height:${height}px}</style> <script>${echartsJs}</script></head><body> <div id="chart"></div> <script> { const chart = echarts.init(document.getElementById('chart'), null, { renderer: 'canvas' }); chart.on('finished', () => { document.body.dataset.ready = 'true'; }); chart.setOption({ ...${safeJson(option)}, animation: false }); } </script></body></html>`; }
The ready signals differ per library. ECharts emits a finished event when rendering is complete, which also covers async work such as loading a map. Chart.js with animation off draws synchronously inside new Chart(), so one animation frame later is safe. With animation on, Chart.js's animation.onComplete callback is the equivalent.
Two bugs from writing this page are worth sharing. First, the script block: we reused one Playwright page for several charts, and the second setContent() failed with Identifier 'config' has already been declared. Inline scripts share one global scope across setContent() calls on the same page. The { ... } block fixes it. Second, safeJson(): chart labels often come from user data, and a label containing </script> would otherwise end the script tag early.
The renderer:
// render-charts.mjs — PNGs for both charts, one browser, one page. import { chromium } from 'playwright'; import { writeFile } from 'node:fs/promises'; import { chartJsPage, echartsPage } from './chart-page.mjs'; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; const signups = [120, 190, 170, 240, 310, 280]; const jobs = [ ['signups-line.png', chartJsPage({ type: 'line', data: { labels: months, datasets: [{ label: 'Sign-ups', data: signups, borderColor: '#2563eb', backgroundColor: 'rgba(37,99,235,.15)', fill: true, tension: 0.3 }] }, })], ['signups-bar.png', echartsPage({ xAxis: { type: 'category', data: months }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: signups, itemStyle: { color: '#16a34a' } }], })], ]; const browser = await chromium.launch(); try { const page = await browser.newPage({ viewport: { width: 800, height: 400 }, deviceScaleFactor: 2 }); for (const [file, html] of jobs) { await page.setContent(html, { waitUntil: 'load' }); await page.locator('body[data-ready="true"]').waitFor({ state: 'attached', timeout: 10_000 }); await writeFile(file, await page.screenshot({ type: 'png' })); } } finally { await browser.close(); }
deviceScaleFactor: 2 gives a 1600 x 800 PNG for an 800 x 400 chart. Both libraries read window.devicePixelRatio, so lines and text stay sharp rather than being scaled up. The timings in the table above were measured at a scale of 1.
Path 2: ECharts without a browser
ECharts has a server-side rendering mode that outputs SVG directly:
// echarts-ssr.mjs — no browser: ECharts to SVG, then SVG to PNG. import * as echarts from 'echarts'; import { Resvg } from '@resvg/resvg-js'; import { writeFile } from 'node:fs/promises'; const chart = echarts.init(null, null, { renderer: 'svg', ssr: true, width: 800, height: 400 }); chart.setOption({ animation: false, backgroundColor: '#fff', xAxis: { type: 'category', data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'] }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: [120, 190, 170, 240, 310, 280], itemStyle: { color: '#16a34a' } }], }); const svg = chart.renderToSVGString(); chart.dispose(); await writeFile('signups-bar.svg', svg); // Pass one font file: scanning all system fonts cost ~0.4 s per call in our test. const resvg = new Resvg(svg, { font: { loadSystemFonts: false, fontFiles: ['./fonts/Inter-Regular.ttf'], defaultFontFamily: 'Inter' }, }); await writeFile('signups-bar.png', resvg.render().asPng());
Set backgroundColor; without it the PNG is transparent, which looks broken in dark-mode email clients. Passing the font file also makes the output the same on every machine, because resvg no longer picks from whatever fonts are installed.
Path 3: Chart.js without a browser
Chart.js needs a canvas. skia-canvas provides one with prebuilt binaries, so there is no Cairo to compile:
// chartjs-skia.mjs — no browser: Chart.js on skia-canvas. import { Chart, registerables } from 'chart.js'; import { Canvas } from 'skia-canvas'; import { writeFile } from 'node:fs/promises'; Chart.register(...registerables); const canvas = new Canvas(800, 400); const chart = new Chart(canvas, { type: 'line', data: { labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], datasets: [{ label: 'Sign-ups', data: [120, 190, 170, 240, 310, 280], borderColor: '#2563eb', backgroundColor: 'rgba(37,99,235,.15)', fill: true, tension: 0.3 }], }, options: { responsive: false, animation: false }, plugins: [{ id: 'white-background', // canvases start transparent beforeDraw: ({ ctx, width, height }) => { ctx.save(); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, width, height); ctx.restore(); }, }], }); await writeFile('signups-line.png', await canvas.toBuffer('png')); chart.destroy();
responsive: false matters: there is no window to measure, so Chart.js must use the canvas size you gave it. For a 2x image, set devicePixelRatio: 2 in the options. Chart.js plugins that touch the DOM, such as HTML legends or tooltips, do not work here.
Path 4: send the chart page to an API
If you do not want Chrome or native canvas modules on your servers, send the same HTML to a rendering API. In this RenderScreenshot request, the page loads Chart.js from a CDN and sets the same ready flag. wait.for_selector with mode attached waits for it, because the flag sits on <body>, which is always visible:
curl -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -d @- -o signups.png <<'JSON' { "html": "<!doctype html><html><head><meta charset=\"utf-8\"><script src=\"https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js\"></script><style>html,body{margin:0;background:#fff}#wrap{width:800px;height:400px}</style></head><body><div id=\"wrap\"><canvas id=\"c\"></canvas></div><script>new Chart(document.getElementById('c'),{type:'bar',data:{labels:['Jan','Feb','Mar','Apr','May','Jun'],datasets:[{label:'Sign-ups',data:[120,190,170,240,310,280],backgroundColor:'#16a34a'}]},options:{responsive:true,maintainAspectRatio:false,animation:false}});requestAnimationFrame(()=>{document.body.dataset.ready='true'});</script></body></html>", "viewport": { "width": 800, "height": 400, "scale": 2 }, "wait": { "for_selector": "body[data-ready=true]", "for_selector_mode": "attached", "timeout": 15 }, "capture": { "mode": "element", "selector": "#wrap" }, "output": { "format": "png" } } JSON
Build the html value with a JSON encoder in real code, and escape every label that comes from users. If the selector never appears, the request fails with a timeout error and uses no credit, which is what you want for a report: no image is better than a wrong one. The wait parameters and wait strategies guide explain the other selector modes.
For a PDF report rather than an image, keep the same page and ask for "output": { "format": "pdf" }. The chart is drawn on a canvas, so it is embedded in the PDF as an image either way.
Charts in email: keep the image alive
Email adds two rules that web pages do not have.
Send PNG, not SVG. Many email clients, Gmail among them, do not display SVG images. Render at twice the display size and set the display size in the tag:
<img src="https://cdn.example.com/charts/weekly-signups.png" width="600" height="300" alt="Sign-ups per month, January to June: 120, 190, 170, 240, 310, 280">
Write the data into the alt text. Many clients block images by default, and screen readers read the alt text.
Host the image for as long as the email exists. People open emails weeks or months later. The X-Cache-URL returned with each render is a public CDN link, but it lives only as long as the cache TTL, at most 30 days. For email, copy the image to storage you control. On paid plans, RenderScreenshot can do that for you with custom storage: add "storage": { "path": "charts/{year}/{month}/{hash}.{ext}" }, which must include {hash} and {ext}, and link to your bucket's copy.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Bars missing, axes present | ECharts captured mid-animation | animation: false and wait for the finished event |
| Line too low or values wrong | Chart.js captured mid-animation | animation: false, or capture in animation.onComplete |
| Chart is 0 x 0 or tiny | Responsive chart in a container with no size | Give the container an explicit width and height |
Identifier 'config' has already been declared |
Inline script reused across setContent() |
Wrap inline scripts in { ... } |
| Blurry lines in the PNG | Rendered at 1x | deviceScaleFactor: 2, viewport.scale: 2 or Chart.js devicePixelRatio: 2 |
| Transparent background in email | Canvas or SVG default | White background plugin, or ECharts backgroundColor |
| Labels in the wrong font | Font not loaded or not installed | Load the web font in the page; pass fontFiles to resvg |
| resvg takes hundreds of ms per chart | It scans all system fonts on each call | loadSystemFonts: false plus fontFiles |
| Wait times out | Ready flag never set, often a script error | Listen for pageerror locally and fix the error first |
| Broken chart page with user data | Label contains </script> |
Escape < in embedded JSON |
Which path should you choose?
- Charts must match the web app exactly (fonts, CSS, plugins): a chart page in headless Chrome, yours or an API's.
- ECharts only, high volume, no browser allowed: ECharts SSR. Send the SVG where SVG is accepted.
- Chart.js only, simple charts, no browser allowed: Chart.js on skia-canvas.
- Emails and PDFs from a web app that already has chart components: send the chart page to an API with a ready selector, and store the PNG yourself.
To try the API path with your own chart page, create a free account and adapt the request in path 4.
Related in this topic
- HTML to Image API: Render HTML and CSS to PNG Send HTML and CSS, get a PNG, JPEG, WebP or PDF back from real Chrome. Base URLs for assets, web fonts, transparent backgrounds and signed URLs.
- Best HTML to Image APIs (2026): 9 Options Compared The best HTML to image APIs compared: rendering engine, ready signals, fonts, delivery, public links and prices at three volumes, from each vendor's own docs.
- html2canvas Alternative: Render the DOM in Real Chromium We tested html2canvas 1.4.1 against real Chromium on 14 CSS features. See what it drops, when it is still the right tool, and how to move capture server-side.
- Convert a Webpage to an Image: 5 Methods, Measured Convert a webpage to a PNG, JPEG or WebP five ways, from a DevTools command to an API. Effort, cost and timings for each, plus the settings that matter.
- HTML to Image in Node.js: 4 Ways, Same 6 Templates HTML to image in Node.js with Puppeteer, node-html-to-image, satori and an API, run on the same six templates. Measured times, what broke, and code that works.
- HTML to Image in Python: imgkit, html2image, Playwright Convert HTML to an image in Python with html2image, Playwright or an API, compared on five templates with timings and renders, and why imgkit is a dead end.