- Where server-rendered charts show up
- Option 1: chartjs-node-canvas
- Option 2: run headless Chrome yourself
- Option 3: use an HTML to image API
- Make the render deterministic
- Getting the PNG into an email or Slack
- Which one to pick
Chart libraries are built for the browser. Chart.js paints to a canvas, ApexCharts writes SVG into the DOM, and both assume there is a window to do it in. The moment you need a chart inside an email, a Slack message or a scheduled report, there is no browser and no DOM.
The fix is to render the chart to a PNG on the server and treat it like any other image. There are three sensible ways to do that. This article walks through each with working code, and is honest about where each one hurts.
Where server-rendered charts show up
Email clients do not run JavaScript, so a live chart is never an option there. Slack and Teams messages accept images, not scripts. Scheduled reports get generated at 6am with nobody's browser involved. And if you want a chart baked into an Open Graph image, the social scrapers only fetch pictures.
The rule of thumb: anywhere the viewer is not running your front end, the chart has to already be a picture.
Option 1: chartjs-node-canvas
If you only need Chart.js and nothing around it, chartjs-node-canvas renders chart configs straight to a buffer using node-canvas. No browser involved.
const { ChartJSNodeCanvas } = require('chartjs-node-canvas');
const fs = require('fs');
const canvas = new ChartJSNodeCanvas({
width: 800,
height: 400,
backgroundColour: 'white',
});
const config = {
type: 'bar',
data: {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
datasets: [{
label: 'Signups',
data: [12, 19, 8, 15, 22],
backgroundColor: '#2563eb',
}],
},
options: {
animation: false,
plugins: { legend: { display: false } },
},
};
const buffer = await canvas.renderToBuffer(config);
fs.writeFileSync('signups.png', buffer);
It works, and for a simple internal report it might be all you need. The pain arrives from node-canvas itself. It is a native module built on Cairo, so installs need system packages and a compiler, which makes it awkward on Alpine images and serverless platforms. Fonts have to be registered by hand or your labels render as empty boxes on a minimal container. And you get the canvas only. If the design calls for a heading, a styled legend or a footer with your logo, none of that exists here, because there is no HTML and no CSS.
Option 2: run headless Chrome yourself
The next step up is to give a real browser a small HTML document and screenshot it. Define the document once, because it is about to be useful twice:
const chartDocument = `<!doctype html>
<html><body>
<canvas id="chart" width="800" height="400"></canvas>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
new Chart(document.getElementById('chart'), {
type: 'bar',
data: {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
datasets: [{ label: 'Signups', data: [12, 19, 8, 15, 22], backgroundColor: '#2563eb' }],
},
options: { animation: false, plugins: { legend: { display: false } } },
});
document.body.classList.add('chart-ready');
</script>
</body></html>`;
Puppeteer renders it exactly as your site would, wrapped in whatever HTML and CSS you like:
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 800, height: 420, deviceScaleFactor: 2 });
await page.setContent(chartDocument, { waitUntil: 'networkidle0' });
await page.waitForSelector('body.chart-ready');
const buffer = await page.screenshot({ type: 'png' });
await browser.close();
The catch is operational rather than technical. You are now running a browser fleet: each Chrome instance wants hundreds of megabytes, crashed pages leave zombie processes behind, and serverless cold starts are slow enough to notice. We have covered keeping headless Chrome alive in production before, and the short version is that it is fine at small volume and a genuine ops burden at scale.
Option 3: use an HTML to image API
The third option is to post that same document to an HTML to image API and let someone else run the browser:
const res = await fetch('https://app.html2img.com/api/html', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.HTML2IMG_KEY,
},
body: JSON.stringify({
html: chartDocument,
width: 800,
height: 420,
dpi: 2,
wait_for_selector: 'body.chart-ready',
}),
});
const { url } = await res.json();
The response contains a hosted URL for the finished PNG, which is convenient because an image destined for an email or a Slack message needs a public URL anyway. There is a worked Chart.js example in the HTML to Image docs if you want to see the full request.
You keep everything Puppeteer gave you, real CSS, your own fonts, a branded wrapper, without operating Chrome. The trade is that it is a paid service beyond the free tier, and your chart data transits a third party, which may matter for sensitive numbers.
Make the render deterministic
Whichever route you pick, charts drift if you let them animate or size themselves. Three settings sort it out.
Turn animation off. With animation: false, Chart.js draws its final frame immediately, so you never capture a half-grown bar.
Fix the dimensions. Set an explicit width and height on the canvas and pass maintainAspectRatio: false, so the chart fills the box you asked for instead of negotiating with its container.
Signal readiness. Add a class in your script once the chart is constructed, then wait for it: waitForSelector in Puppeteer, wait_for_selector on the API. Waiting a fixed number of milliseconds works right up until the day the CDN is slow.
And render at double resolution for retina screens, either deviceScaleFactor: 2 or dpi: 2, then display at half size.
Getting the PNG into an email or Slack
For email, reference the hosted image with an explicit width and meaningful alt text, so recipients with images switched off still learn the headline number:
<img src="https://i.example.com/signups-week-32.png"
width="600" alt="Signups this week: 76, up 18% on last week"
style="width: 100%; max-width: 600px; height: auto;">
For Slack, drop the URL into an image block:
{
"blocks": [
{ "type": "section", "text": { "type": "mrkdwn", "text": "*Weekly signups*" } },
{ "type": "image", "image_url": "https://i.example.com/signups-week-32.png", "alt_text": "Signups this week: 76" }
]
}
If the chart lives on a dashboard your users can already see, pair this with an export button so they can pull the same image themselves. We covered that pattern in Add "Export as image" to any dashboard.
Which one to pick
Reach for chartjs-node-canvas when the output is a bare Chart.js chart and you control the server image. Run Puppeteer when you need full fidelity, have modest volume and do not mind the ops. Use an HTML to image API when you want browser-grade output without owning the browser. In every case: animation off, fixed sizes, a readiness signal, and render at 2x.