# Chart to image: three ways to turn data into a PNG, measured

> Turn data into a chart image: Chart.js in headless Chrome, a Chart.js image service, or a designed chart template. Code, render times and the traps.

Published 2026-11-08 · https://shareglint.com/en/guides/chart-to-image-api

A chart as an image is what you need wherever JavaScript does not run: an email, a Slack or Discord message, a social post, a PDF report, an Open Graph image for a stats page. There are three ways to make one, and they trade flexibility against effort differently. This page shows each with tested code and the times we measured.

**How we measured.** On 2026-11-08: Chart.js 4.6.1 in Puppeteer 25.7's headless Chrome on our machine, QuickChart's public endpoint over the internet, and our chart templates through our API on our development stack. Same data everywhere: six weeks of signups, 412 to 611 (example numbers).

## 1. Chart.js in headless Chrome

The most flexible: any chart Chart.js can draw, styled exactly as on your site. The trap is the animation.

```javascript
import puppeteer from 'puppeteer';

const html = `<!doctype html><body style="margin:0;background:#fff">
<canvas id="c" width="1200" height="630"></canvas>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.6.1/dist/chart.umd.min.js"></script>
<script>
  window.chart = new Chart(document.getElementById('c'), {
    type: 'bar',
    data: { labels: ['W1','W2','W3','W4','W5','W6'],
            datasets: [{ label: 'Signups', data: [412,388,455,497,530,611], backgroundColor: '#0ea5e9' }] },
    options: { responsive: false, animation: false, plugins: { legend: { display: false } } },
  });
</script></body>`;

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 630 });
await page.setContent(html, { waitUntil: 'load' });
await page.screenshot({ path: 'chart.png' });
await browser.close();
```

What the animation does to the picture, measured by the blue bar area in the screenshot:

| Chart options and wait | Bar area | Time to the screenshot |
|---|---|---|
| Default animation, screenshot at `load` | 16% of the final | 0.2–0.5 s |
| `animation: false` | 100% | 0.19 s |
| Default animation, then `waitForFunction(() => !Chart.animator.running(window.chart))` | 100% | 1.18 s |

So turn animation off for images ([Chart.js animations](https://www.chartjs.org/docs/latest/configuration/animations.html)). Two more settings matter: `responsive: false` with a fixed canvas size, so the chart does not resize to the viewport, and `deviceScaleFactor: 2` in `setViewport` for a sharp picture on retina screens. The rest of this approach is running Chrome yourself — memory, fonts, updates — the same as any [Puppeteer screenshot](https://shareglint.com/en/guides/puppeteer-screenshot).

## 2. A Chart.js image service

[QuickChart](https://github.com/typpo/quickchart) is an open-source (AGPL-3.0) "chart image and QR code web API": you send a Chart.js configuration in the URL and get a PNG back, with a hosted endpoint at quickchart.io or your own instance.

```bash
C='{"type":"bar","data":{"labels":["W1","W2","W3","W4","W5","W6"],"datasets":[{"label":"Signups","data":[412,388,455,497,530,611]}]}}'
curl -s -o chart.png -G https://quickchart.io/chart \
  --data-urlencode "c=$C" --data-urlencode "width=1200" --data-urlencode "height=630"
```

It answered in 1.0 s and then 0.6 s with a 2400×1260 PNG of 37 KB (it draws at twice the size you ask for). No browser to run, any Chart.js chart, and the URL works in an `<img>` or an email. What it does not give you is a design around the chart: title, branding, source line and layout are yours to build in the Chart.js config.

## 3. A chart template

The third way starts from the finished image: a designed card with the chart, a title, a unit line, a source and your logo, filled from data. Our API has three such templates, and the data goes in as plain text:

```bash
curl -X POST "https://shareglint.com/v1/images?wait=true" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"template": "bar-chart", "modifications": {
        "title": "Weekly signups",
        "values": "W1:412, W2:388, W3:455, W4:497, W5:530, W6:611",
        "unit": "Signups",
        "source": "Source: product analytics"}}'
```

| Template | Data field | What we rendered | Time |
|---|---|---|---|
| `bar-chart` | `values`: "Label:number, …" | 6 weeks of signups | 1.08 s |
| `line-chart` | `series`, plus `highlight` for one point | 6 months of a falling median | 0.99 s |
| `donut-chart` | `segments`, plus `center_value` | 5 traffic sources, 48k in the centre | 0.67 s |

Each came back as a 2400×1260 PNG: the 1200×630 card at the default 2× scale. Ask for `"size": "square"` and the same chart comes out 1080×1080 (2160×2160 at 2×); every [size preset](https://shareglint.com/en/social-media-image-sizes) works.

The parser is forgiving because spreadsheet data is messy: commas, semicolons or new lines between values; "1,200", "1.200,5" and "12 400" read as numbers; a part without a number is skipped instead of failing the image. And it has limits worth knowing:

- `bar-chart` draws at most **12 bars** — we sent 14 and got the first 12 — and a negative value is drawn as a bar of zero height with its label ("-3") kept.
- `line-chart` takes up to **24 points**; beyond that the segments get shorter than the dots.
- `donut-chart` shows the **six largest** segments; the rest stays in the ring as one unlabelled grey arc, so the shares still add up. A segment can carry its own colour ("Search:42 #16a34a").

What a template does not do is draw an arbitrary chart type: no scatter plots, stacked bars, dual axes or maps. For those, Chart.js (ways 1 or 2) is the tool. Templates also cannot load Chart.js themselves — scripts and external files are stripped from template HTML — so a custom chart template is drawn with HTML and CSS.

## Which one?

| You need | Use |
|---|---|
| Any chart type, your own styling, you run servers anyway | Chart.js in headless Chrome |
| Any Chart.js chart, no browser to run | A Chart.js image service |
| A branded card with one chart, in many sizes, filled from a sheet or a webhook | A chart template |

The template route fits recurring images: a weekly stats post, a "your month in numbers" email per customer, an OG image for a public dashboard. Fill it from a CSV or Google Sheets row by row ([bulk images from a sheet](https://shareglint.com/en/guides/bulk-og-images-from-csv-google-sheets)), or put a signed URL in the page so the image renders on first view ([signed image URLs](https://shareglint.com/en/guides/signed-image-urls)).

One more case: a chart that already lives on a public web page (your dashboard, a report) can be captured as it is with a screenshot and `selector` for just the chart element. Our screenshot capture waits for the network to go quiet but not for animations, so add `"delay": 2` for animated charts ([blank or blurry screenshots](https://shareglint.com/en/guides/puppeteer-screenshot-blank-or-blurry) explains why).

A template render costs one render of the quota at the default 2× scale; the same data again within the cache period is free ([pricing](https://shareglint.com/en/pricing)).

## FAQ

### How do I convert a chart to an image?

Render it where a browser can draw it and save the picture. Chart.js in headless Chrome with page.screenshot() works if you turn the animation off; a chart image service such as QuickChart takes a Chart.js config in a URL and returns a PNG; a chart template takes labels and values and returns a finished, branded image.

### Why is my Chart.js screenshot showing half-drawn bars?

Chart.js animates by default for about a second. A screenshot taken at load caught the bars at 16% of their final area in our test. Set animation: false in the chart options, or wait until Chart.animator.running(chart) is false before the screenshot (1.18 s in our test).

### How many values can a chart template show?

Our bar-chart template draws up to 12 bars, line-chart up to 24 points and donut-chart its six largest segments, with the rest as one grey arc so the shares still add up. Values go in as text such as "W1:412, W2:388"; a part without a number is skipped instead of failing the image.
