ScreenshotNeo

BlogHow-to

How to Capture Chart.js Charts with Puppeteer

Capture a finished Chart.js chart with Puppeteer. Set predictable dimensions, wait for rendering, and choose between a page screenshot and canvas export.

By the ScreenshotNeo team29 September 202610 min read

How to Capture Chart.js Charts with Puppeteer

To capture a Chart.js chart with Puppeteer, set the page viewport and chart container dimensions, wait until the chart has finished rendering, then save it with page.screenshot(). Disable Chart.js animation for deterministic static output, or signal readiness from animation.onComplete when the animation itself must finish. Use chart.toBase64Image() instead when you need only the canvas image and not the surrounding page.

This guide uses Puppeteer with Node.js. It covers a complete runnable example, responsive sizing, readiness, screenshot bounds, canvas export, common failures, and operational tradeoffs. Check the installed Chart.js major version and use its matching documentation; the current getting-started guide covers v4, while v3 introduced substantial API changes from v2.

1. Install Puppeteer and prepare the capture

Start with a small project and install Puppeteer. The example below loads a self-contained HTML page with Chart.js from a CDN, waits for a chart-specific ready flag, then writes a PNG screenshot.

A fixed viewport and chart container make the rendered page easier to capture consistently.
A fixed viewport and chart container make the rendered page easier to capture consistently.
mkdir chart-capture
cd chart-capture
npm init -y
npm install puppeteer

Save the following as capture-chart.js. In production, pin package versions in your lockfile and consider serving Chart.js and other assets locally if captures must be reproducible without relying on a third-party CDN.

const puppeteer = require('puppeteer');

const html = `
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    * { box-sizing: border-box; }
    body { margin: 0; padding: 24px; font: 14px sans-serif; background: #f5f7fb; }
    #card { width: 900px; padding: 20px; background: white; border-radius: 12px; }
    #chart-wrap { position: relative; width: 860px; height: 440px; }
    canvas { display: block; }
  </style>
</head>
<body>
  <main id="card">
    <h1>Monthly revenue</h1>
    <div id="chart-wrap"><canvas id="chart"></canvas></div>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  <script>
    window.chartReady = false;
    const chart = new Chart(document.getElementById('chart'), {
      type: 'line',
      data: {
        labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
        datasets: [{
          label: 'Revenue',
          data: [12, 19, 15, 27, 24, 34],
          borderColor: '#315efb',
          backgroundColor: 'rgba(49, 94, 251, .12)',
          fill: true,
          tension: .25
        }]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        animation: false,
        plugins: { legend: { position: 'bottom' } }
      }
    });
    window.chartReady = true;
    window.chartInstance = chart;
  </script>
</body>
</html>`;

(async () => {
  const browser = await puppeteer.launch({ headless: true });
  try {
    const page = await browser.newPage();
    await page.setViewport({ width: 1000, height: 700, deviceScaleFactor: 1 });
    await page.setContent(html, { waitUntil: 'load', timeout: 30000 });
    await page.waitForFunction(() => window.chartReady === true, { timeout: 15000 });
    await page.screenshot({ path: 'chart.png', type: 'png' });
    console.log('Saved chart.png');
  } finally {
    await browser.close();
  }
})().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

Run it with node capture-chart.js. The viewport is deliberately wider than the card so the page has room around the capture. The wrapper has explicit dimensions and maintainAspectRatio: false, which lets its height control the chart. Chart.js recommends a dedicated, relatively positioned container because the canvas’s CSS display size and render size are distinct. See the Chart.js responsive charts guide.

2. Make chart readiness explicit

A loaded document is not proof that the chart is ready. The canvas can exist before Chart.js draws it, and a chart with animation enabled can still be in an intermediate frame after its data and scripts have loaded.

For static captures, disable animation

For reports, previews, and other still images, set options.animation: false. This removes animation timing from the capture path. The example sets window.chartReady immediately after constructing the chart with animation disabled. If data arrives asynchronously, set the flag only after data has been applied and the chart updated.

When animation must run, wait for its completion callback

Chart.js supports an animation completion callback. Make that callback set a page-owned flag, then have Puppeteer wait for the flag before capturing:

// In the page's Chart.js configuration:
window.chartReady = false;
const chart = new Chart(canvas, {
  type: 'bar',
  data,
  options: {
    animation: {
      onComplete() {
        window.chartReady = true;
      }
    }
  }
});

// In the Puppeteer script, after navigation or page content setup:
await page.waitForFunction(() => window.chartReady === true, {
  timeout: 20000
});
await page.screenshot({ path: 'animated-chart.png' });

Initialize the flag before creating the chart so the wait cannot miss a fast completion. If application code replaces the dataset or calls chart.update(), reset the flag before that update and set it again from the next completion callback.

page.waitForNetworkIdle() can help ensure network requests have settled, but it does not indicate that Chart.js has finished drawing or animating. Puppeteer’s current API reference describes a default network-idle interval of 500 ms; verify defaults for the version installed in your project. Treat network idle and chart readiness as separate conditions. See Puppeteer’s network-idle API and Chart.js animations.

3. Choose the screenshot boundary and image format

page.screenshot() captures the rendered page. It is the right choice when the deliverable includes a title, HTML labels, card background, page styling, or other DOM around the canvas. Puppeteer screenshots default to PNG and support options such as fullPage, clip, and omitBackground. Refer to the Puppeteer screenshot method and its screenshot options.

Choose a page screenshot for surrounding layout, or canvas export for chart pixels alone.
Choose a page screenshot for surrounding layout, or canvas export for chart pixels alone.
// Whole visible viewport
await page.screenshot({ path: 'viewport.png' });

// Entire scrollable page
await page.screenshot({ path: 'full-page.png', fullPage: true });

// A specific rectangle in page coordinates
await page.screenshot({
  path: 'chart-region.png',
  clip: { x: 44, y: 80, width: 900, height: 500 }
});

// Transparent page background where supported by the page design
await page.screenshot({ path: 'transparent.png', omitBackground: true });

A clip uses page coordinates, so derive or inspect its position after layout is complete. Include axis tick labels, titles, and legends: they may extend beyond the canvas or the crop rectangle. For a crop that follows a responsive element, measure its bounding box in the page and use that rectangle rather than hard-coding coordinates:

const box = await page.$eval('#card', el => {
  const r = el.getBoundingClientRect();
  return { x: r.x, y: r.y, width: r.width, height: r.height };
});
await page.screenshot({ path: 'card.png', clip: box });

4. Export only the Chart.js canvas

If the image should contain only chart pixels, Chart.js exposes chart.toBase64Image(). This avoids capturing the page background or nearby DOM. Call it only after the chart is ready. The API returns a base64 data URL; PNG is the default, and JPEG can be requested with a quality value. See the Chart.js developer API.

const dataUrl = await page.evaluate(() => {
  return window.chartInstance.toBase64Image('image/png');
});
const base64 = dataUrl.split(',')[1];
require('fs').writeFileSync('canvas.png', Buffer.from(base64, 'base64'));

For JPEG output, ask Chart.js for image/jpeg and choose a quality value between the browser-supported bounds, then decode the returned data URL the same way. JPEG is lossy and does not preserve transparency; PNG is generally a safer choice for sharp chart text, thin lines, or transparent output. If you need the page’s HTML legend or labels outside the canvas, use a page screenshot instead.

5. Control dimensions, responsiveness, and redraws

Set the Puppeteer viewport before navigating or rendering, and size the chart’s parent container. A stable viewport makes responsive layout and output dimensions predictable. Chart.js responsiveness tracks the parent container; setting percentage width and height directly on the canvas can produce surprising render dimensions.

When changing the viewport or container after chart creation, wait for Chart.js to resize and redraw before capturing. Its API includes resize(); normally its responsive behavior handles the change, but the capture still needs an application readiness condition if a redraw is asynchronous. For print-oriented output, explicitly set the desired dimensions and coordinate the redraw: browser print layout and resize event timing can affect charts. See the Chart.js API.

For sharper output, increase Puppeteer’s deviceScaleFactor when setting the viewport. This increases the screenshot’s physical pixel dimensions relative to CSS pixels, with a corresponding increase in image size and rendering work. Confirm the resulting dimensions with the target consumer rather than assuming a retina setting is required.

6. ScreenshotNeo option: capture without maintaining Puppeteer

If you need a screenshot artifact but do not want to run a browser process, ScreenshotNeo provides a website screenshot API and MCP server. For a publicly reachable page that renders the chart, a single GET request can return an image or PDF. The service does not execute arbitrary page setup code from your local script, so make sure the page itself exposes the chart at a stable URL with data and dimensions ready.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://example.com/chart \
  -o chart.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com/chart"},
    timeout=90,
)
r.raise_for_status()
open("chart.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://example.com/chart'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('chart.webp', bytes);

See the ScreenshotNeo API documentation for request options and response details. ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers indicate the page verdict and billing status. Its MCP server offers take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 shots per month without a card; paid plans start at $5 for 3,000 shots. Every feature is on every plan.

Create a free ScreenshotNeo account to get 1,000 screenshots a month with no card.

7. Reliability, performance, and cost considerations

For a Puppeteer workflow, most reliability problems come from unstable page inputs: changing data, remote fonts or scripts, variable layout, and unclear readiness. Keep the viewport, data snapshot, locale, and time zone consistent when output must be repeatable. Prefer an explicit chart-ready signal over a guessed delay. A timeout should fail the capture visibly and be logged with the page URL and error rather than silently saving a blank artifact.

Browser capture has setup and resource costs: a Chromium process consumes memory and CPU, and each page must load its required code and assets. Reuse a browser across a batch of captures where practical, while creating a fresh page per independent job and closing pages in cleanup. Avoid launching more parallel pages than the host can support. Chart.js performance guidance covers rendering approaches for large datasets; simplify or decimate data when the visualization itself is the bottleneck, not the screenshot call. See Chart.js performance guidance.

Network dependencies are a reliability and privacy consideration. A CDN failure can leave an empty canvas, and remote data may change between runs. For controlled exports, serve versioned assets and fixed data from a known environment. Do not put credentials in page URLs or logs. With a hosted screenshot API, account for plan limits and the fact that the target page must be reachable by the service. ScreenshotNeo documents its plans as Free 1,000 monthly shots, Starter $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000; yearly billing gives two months free.

8. Troubleshooting common capture failures

Symptom Likely cause Fix
Blank chart or empty canvas Canvas appeared before Chart.js loaded, chart initialization failed, or the chart is not ready. Wait for the library and an application-owned ready flag. Check browser console errors and confirm data and chart construction succeeded.
Partial animation frame Screenshot started while Chart.js animation was running. Use animation: false for static images or wait for animation.onComplete.
Network idle wait passes, chart is still incomplete Network quiet does not imply render or animation completion. Keep network-idle waiting for assets if useful, then separately wait for the chart-specific state.
Chart appears squashed or has unexpected height Responsive sizing is driven by a differently sized parent, or aspect ratio is still enforced. Give a dedicated positioned wrapper explicit dimensions and set maintainAspectRatio: false when wrapper height should govern.
Screenshot clips labels or legend Crop bounds cover the canvas but not adjacent HTML or chart decoration. Measure the containing element after layout and include margins for ticks, title, and legend.
Output is blurry or unexpectedly large Viewport scale factor and CSS dimensions do not match the intended pixel dimensions. Set viewport and deviceScaleFactor deliberately, then inspect output dimensions and file size.
Chart differs between runs Remote assets, fonts, data, viewport, or animation timing changed. Pin dependencies, stabilize data and dimensions, wait for fonts/assets where required, and disable animation for static capture.
Timeout waiting for readiness The page never sets the flag, or an earlier script error stopped initialization. Set the flag on every successful render path, surface page errors, and fail with a diagnostic instead of increasing the timeout blindly.

9. FAQ

Can Puppeteer capture a Chart.js chart without saving the whole page?

Yes. Read chart.toBase64Image() after the chart is ready and decode its data URL in Node.js. Use a page screenshot when HTML surrounding the canvas belongs in the result.

Is waiting for DOMContentLoaded enough?

No. It signals document parsing, not chart initialization, data completion, or animation completion. Wait for a page condition tied to the chart.

Which Chart.js version should I use?

Use the documentation matching the version installed by your project. The current getting-started material discusses v4, and the developer documentation notes significant API changes in v3 from v2. See Getting Started and the developer documentation.

Can I capture a chart that is behind a login?

Puppeteer can navigate to a page using a session your application establishes, but credentials and session handling are specific to that page. Keep secrets out of source control and logs, and ensure the browser has permission to access the chart data.

Does a full-page screenshot mean only the chart?

No. fullPage captures the page’s full scrollable extent. Use an element crop or canvas export when the output should be limited to the chart.