Skip to content

Generating charts on the server

amCharts 5 needs a browser to draw: it renders to Canvas or SVG and measures text with the browser’s layout engine. To make chart images on a server, run the chart in a headless browser and take the result out with the Exporting API.

This page uses Puppeteer, which drives headless Chrome from Node.js. Playwright works the same way: only the launch code differs. For Firefox, see Using Selenium.

Typical uses:

  • Images for emails and messaging apps, which can’t run JavaScript.
  • Scheduled PDF reports.
  • An HTTP endpoint that returns a chart image for given data.
  • Thumbnails and previews.
  • Snapshots of charts on existing pages, such as dashboards.
  1. Node.js starts headless Chrome once.
  2. For each chart, it opens a page that loads amCharts and builds the chart with the given data.
  3. The page waits until the chart has finished drawing.
  4. The exporting plugin returns the image, which Node.js saves or sends.

Requires Node.js 18 or later.

Terminal window
mkdir chart-renderer
cd chart-renderer
npm init -y
npm install puppeteer

Puppeteer downloads a matching Chrome build on install. On Linux servers, Chrome also needs some system libraries; see Running in Docker.

Set "type": "module" in package.json to use the import syntax below.

The page loads amCharts and defines one function, renderChart(), which Node.js calls with the data and export options. Save it as chart.html:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; }
</style>
<script src="https://cdn.amcharts.com/lib/5/index.js"></script>
<script src="https://cdn.amcharts.com/lib/5/xy.js"></script>
<script src="https://cdn.amcharts.com/lib/5/plugins/exporting.js"></script>
</head>
<body>
<div id="chartdiv"></div>
<script>
var root;
// Resolves when no frame has been drawn for 100ms
function chartReady(root) {
return new Promise(function(resolve) {
var timeout;
root.events.on("frameended", function onFrame() {
clearTimeout(timeout);
timeout = setTimeout(function() {
root.events.off("frameended", onFrame);
resolve();
}, 100);
});
});
}
async function renderChart(data, options) {
// Reuse the page: remove the previous chart
if (root) {
root.dispose();
}
var div = document.getElementById("chartdiv");
div.style.width = options.width + "px";
div.style.height = options.height + "px";
root = am5.Root.new("chartdiv");
var ready = chartReady(root);
// No Animated theme and no appear() calls: the chart is drawn
// in its final state right away
var chart = root.container.children.push(am5xy.XYChart.new(root, {}));
var xAxis = chart.xAxes.push(am5xy.CategoryAxis.new(root, {
categoryField: "category",
renderer: am5xy.AxisRendererX.new(root, { minGridDistance: 30 })
}));
xAxis.data.setAll(data);
var yAxis = chart.yAxes.push(am5xy.ValueAxis.new(root, {
min: 0,
renderer: am5xy.AxisRendererY.new(root, {})
}));
var series = chart.series.push(am5xy.ColumnSeries.new(root, {
xAxis: xAxis,
yAxis: yAxis,
valueYField: "value",
categoryXField: "category"
}));
series.data.setAll(data);
await ready;
var exporting = am5plugins_exporting.Exporting.new(root, {
backgroundColor: am5.color(0xffffff)
});
if (options.format == "svg") {
return exporting.getSVG();
}
return exporting.export(options.format);
}
</script>
</body>
</html>

What matters here:

  • No animations. Don’t use the Animated theme or call appear(). Otherwise the image may catch the chart mid-animation, and every render takes longer.
  • Fixed size in pixels. The chart takes the size of its container.
  • Wait for the last frame. The chart draws over several frames. chartReady() waits until drawing stops. See “Chart ready event”.
  • One function per page. Node.js passes only data and options, not code.

render.js opens the page and calls renderChart(). It exports a function, so the same code serves scripts, scheduled jobs and the HTTP service below.

import puppeteer from "puppeteer";
import { writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
const PAGE_URL = new URL("chart.html", import.meta.url).href;
export async function openChartPage(browser, scale = 2) {
// Own context = own window. Chrome doesn't draw background tabs,
// so pages sharing a window would hang.
const context = await browser.createBrowserContext();
const page = await context.newPage();
// deviceScaleFactor sets the image resolution: 2 = twice the chart size
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: scale });
await page.goto(PAGE_URL, { waitUntil: "load" });
return page;
}
export async function renderChart(page, data, options) {
options = { width: 800, height: 500, format: "png", timeout: 15000, ...options };
let timer;
const timeout = new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error("Rendering timed out")), options.timeout);
});
let result;
try {
result = await Promise.race([
page.evaluate((data, options) => renderChart(data, options), data, options),
timeout
]);
} finally {
clearTimeout(timer);
}
if (options.format == "svg") {
return Buffer.from(result);
}
// Data URI → binary
return Buffer.from(result.split(",")[1], "base64");
}
// Run directly: node render.js
if (import.meta.url == pathToFileURL(process.argv[1]).href) {
const browser = await puppeteer.launch();
try {
const page = await openChartPage(browser);
const data = [
{ category: "Research", value: 1000 },
{ category: "Marketing", value: 1200 },
{ category: "Sales", value: 850 }
];
await writeFile("chart.png", await renderChart(page, data));
await writeFile("chart.svg", await renderChart(page, data, { format: "svg" }));
await writeFile("chart.pdf", await renderChart(page, data, { format: "pdf" }));
} finally {
await browser.close();
}
}
Terminal window
node render.js
Format Call in the page Notes
PNG, JPG exporting.export("png") Resolution follows deviceScaleFactor. Set minWidth/maxWidth to fix the pixel size: see “Sizing exported image”.
SVG exporting.getSVG() Vector; text stays text. am5.renderToSVG(root) does the same without the plugin. See “Exporting to SVG”.
PDF exporting.export("pdf") A page with the chart image. Options such as page size and an attached data table: see “Exporting to PDF”.
CSV, XLSX… exporting.export("xlsx") Needs dataSource. See “Exporting data”.

To put charts into a larger document with text and tables, build the report as an HTML page and print it with Puppeteer’s page.pdf(). Set the root’s renderer to SVG so the charts stay vector in the PDF:

var root = am5.Root.new("chartdiv", {
renderer: am5.SVGRenderer
});
await page.evaluate(() => chartsReady); // a promise your report page sets
await page.pdf({ path: "report.pdf", format: "A4", printBackground: true });

am5.SVGRenderer is available since version 5.21.0. See “Renderer”.

To capture a chart from a page that already exists, open its URL and find the chart with am5.registry.rootElements. Wait until it stops drawing, including its animations, then take the image:

import puppeteer from "puppeteer";
import { writeFile } from "node:fs/promises";
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
await page.goto("https://example.com/dashboard/", { waitUntil: "networkidle0" });
const svg = await page.evaluate(async () => {
const root = am5.registry.rootElements[0];
// Wait until no frame has been drawn for 300ms
await new Promise((resolve) => {
let timeout = setTimeout(done, 300);
function onFrame() {
clearTimeout(timeout);
timeout = setTimeout(done, 300);
}
function done() {
root.events.off("frameended", onFrame);
resolve();
}
root.events.on("frameended", onFrame);
});
return am5.renderToSVG(root);
});
await writeFile("chart.svg", svg);
// Or a PNG screenshot of the chart's element
const chartElement = await page.evaluateHandle(() => am5.registry.rootElements[0].dom);
await chartElement.screenshot({ path: "chart.png" });
} finally {
await browser.close();
}

This needs no exporting plugin on the page. The screenshot shows the chart as laid out on the page, including anything drawn over it; renderToSVG() gives the chart alone. Unlike chartReady() above, the timer starts right away, so it also works when the chart has already finished drawing.

  • The page must load amCharts from script tags, which define the global am5. Pages that bundle amCharts don’t expose it.
  • rootElements[0] is the first chart on the page. For a specific one, use am5.registry.getRootById("chartdiv") with its container’s id.
  • Pages behind a login need the login done first: fill in the form with Puppeteer, or set the session cookie with browser.setCookie().

Instead of hard-coding one chart in the page, the page can build any chart from a JSON config with the JSON plugin. Configs are plain data, so they can be stored, edited in Chart Maker or generated by another service.

Load the plugin and the chart modules the configs use:

<script src="https://cdn.amcharts.com/lib/5/plugins/json.js"></script>
<script src="https://cdn.amcharts.com/lib/5/percent.js"></script>

Then replace the chart-building code in renderChart():

async function renderChart(config, options) {
// ...dispose the previous root, size the div, create root and ready, as above
await am5plugins_json.JsonParser.new(root).parse(config, {
parent: root.container
});
await ready;
// ...export as above
}

An endpoint that takes data and returns a PNG. It keeps one browser and a few open pages, and gives each request a free page. Install Express first: npm install express.

import express from "express";
import puppeteer from "puppeteer";
import { openChartPage, renderChart } from "./render.js";
const POOL_SIZE = 4;
const browser = await puppeteer.launch();
const freePages = [];
const waiting = [];
for (let i = 0; i < POOL_SIZE; i++) {
freePages.push(await openChartPage(browser));
}
function takePage() {
if (freePages.length) {
return Promise.resolve(freePages.pop());
}
return new Promise((resolve) => waiting.push(resolve));
}
function releasePage(page) {
const next = waiting.shift();
if (next) {
next(page);
} else {
freePages.push(page);
}
}
const app = express();
app.use(express.json({ limit: "1mb" }));
app.post("/chart.png", async (req, res) => {
const page = await takePage();
try {
const image = await renderChart(page, req.body.data, {
width: Math.min(Number(req.body.width) || 800, 2000),
height: Math.min(Number(req.body.height) || 500, 2000)
});
res.type("png").send(image);
} catch (e) {
console.error(e);
res.status(500).send("Rendering failed");
} finally {
releasePage(page);
}
});
app.listen(3000);
Terminal window
curl -X POST http://localhost:3000/chart.png \
-H "Content-Type: application/json" \
-d '{"data":[{"category":"A","value":10},{"category":"B","value":20}]}' \
-o chart.png

Selenium WebDriver drives Firefox as well as Chrome. It works with the same chart.html:

Terminal window
npm install selenium-webdriver
import { Builder } from "selenium-webdriver";
import firefox from "selenium-webdriver/firefox.js";
import { writeFile } from "node:fs/promises";
const options = new firefox.Options()
.addArguments("-headless")
// Image resolution: 2 = twice the chart size
.setPreference("layout.css.devPixelsPerPx", "2");
const driver = await new Builder()
.forBrowser("firefox")
.setFirefoxOptions(options)
.build();
try {
await driver.manage().setTimeouts({ script: 15000 });
await driver.get(new URL("chart.html", import.meta.url).href);
const data = [
{ category: "Research", value: 1000 },
{ category: "Marketing", value: 1200 },
{ category: "Sales", value: 850 }
];
// The last argument is the callback that returns the result to Node.js
const result = await driver.executeAsyncScript(function(data, options, done) {
renderChart(data, options).then(done);
}, data, { width: 800, height: 500, format: "png" });
await writeFile("chart.png", Buffer.from(result.split(",")[1], "base64"));
} finally {
await driver.quit();
}

Selenium finds or downloads the browser driver itself; Firefox has to be installed. The script timeout ends renders that never finish.

  • Start the browser once. Launching Chrome takes about a second; opening a page much less, and reusing a loaded page (as above) less still.
  • Limit parallel renders. Each page uses tens of MB of memory and a CPU core while drawing. A pool of about one page per CPU core is a good start.
  • Cache results. Same data and options give the same image: key the cache on a hash of them.
  • Recover from crashes. Listen for browser.on("disconnected") and launch a new browser. Restarting it every few thousand renders also keeps memory in check.
  • Keep the time limit. renderChart() gives up after timeout ms, so a chart that never finishes drawing doesn’t hold a page forever. Replace a page that timed out with a new one.
  • One context per page. Chrome doesn’t draw tabs in the background. openChartPage() opens each page in its own browser context, so pooled pages render in parallel.

chart.html above loads amCharts from the CDN on every page load. For production, serve your own copy so renders don’t depend on an outside service and the version stays fixed.

Install amCharts from npm and bundle it with the page’s script, using a bundler such as esbuild:

Terminal window
npm install @amcharts/amcharts5 esbuild

Move the inline script from chart.html into chart-page.js, with imports at the top:

import * as am5 from "@amcharts/amcharts5";
import * as am5xy from "@amcharts/amcharts5/xy";
import * as am5plugins_exporting from "@amcharts/amcharts5/plugins/exporting";
// chartReady() and renderChart() from chart.html, unchanged
window.renderChart = renderChart;
Terminal window
npx esbuild chart-page.js --bundle --outfile=chart-page.bundle.js

In chart.html, replace all <script> tags with <script src="chart-page.bundle.js"></script>. The bundle includes everything, including the libraries for PDF and XLSX export, so the page makes no outside requests.

The headless browser draws text with the fonts installed on the server. Servers often have few fonts, so labels may look different from your desktop, and characters missing from all fonts show as boxes.

  • Install the fonts you need, e.g. on Debian/Ubuntu: apt-get install fonts-noto fonts-noto-cjk.
  • Or load a web font with @font-face in the page, wait for it before building the chart, and set it on the root:
await document.fonts.load("14px Inter");
root = am5.Root.new("chartdiv", {
fontFamily: "Inter"
});

fontFamily on the root is available since version 5.20.2.

PDF export has its own fonts: see “Exporting to PDF”.

Puppeteer’s Chrome needs system libraries that slim Linux images don’t have. The simplest setup installs Chromium from the distribution and tells Puppeteer to use it:

FROM node:22-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium fonts-noto fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_SKIP_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
USER node
CMD ["node", "server.js"]

If Chrome fails to start with a sandbox error, you can launch it with puppeteer.launch({ args: ["--no-sandbox"] }). Do that only if the charts come from data you trust: the sandbox is what isolates Chrome from the server.

When a service renders data sent by others:

  • Pass data, not code. Send data and options to page.evaluate() as arguments, never build JavaScript strings from request input.
  • Keep HTML sanitation on. Labels and tooltips with HTML are cleaned by default (sanitizeHTML root setting). See “HTML sanitation”.
  • Block network access the chart doesn’t need, so input can’t make the server fetch internal URLs:
await page.setRequestInterception(true);
page.on("request", (request) => {
const url = request.url();
if (url.startsWith("file:") || url.startsWith("https://cdn.amcharts.com/")) {
request.continue();
} else {
request.abort();
}
});
  • Limit sizes: request body, number of data points, and chart width and height.

With a commercial license, register it in the chart page before any chart is created, as on any other page:

am5.addLicense("AM5C-XXXX-XXXX-XXXX-XXXX");
Problem Cause and fix
Image is blank or partly drawn Exported before drawing finished. Wait for chartReady(), and for any data loaded with am5.net.load() before that.
Chart caught mid-animation Remove the Animated theme and appear() calls.
Image is blurry or the wrong size Set deviceScaleFactor in setViewport(), or minWidth/maxWidth in the image options.
Boxes instead of letters, wrong font The font isn’t on the server. See Fonts.
Renders hang when several pages are open Pages share a window, and only the front tab draws. Open each page in its own browser context, as openChartPage() does.
renderChart is not defined The page didn’t finish loading, or a script failed. Log the page’s errors: page.on("console", m => console.log(m.text())) and page.on("pageerror", console.error).
Chrome fails to start on Linux Missing system libraries or sandbox. See Running in Docker.
Memory grows over time Dispose each root before making the next one, close pages you no longer use, and restart the browser now and then.