You need a chart as a static image on the server — to attach to an email, embed in a generated PDF, post to Slack, or drop into a nightly report. Not an interactive widget in a browser; a PNG, generated headless, no human watching.
Here's the thing nobody says up front: almost every server-side "chart to image" route eventually drags in one of two heavy dependencies — a C/C++ canvas stack (Cairo/Skia via node-canvas) or a headless Chrome. The choice is really which one you want to own. This guide walks the honest options across Node and Python (verified June 2026, including the kaleido and orca changes that broke a lot of old tutorials), then the route that moves the browser off your box entirely.
Method 1: Render the chart's HTML with an API (no canvas, no Chrome to host)
If your chart can be expressed as HTML — a Chart.js <canvas>, an ECharts container, an SVG, or even a styled HTML/CSS bar chart — you can POST that HTML to the Rendex rendering API and get a PNG back. Rendex runs a real, modern headless browser on the edge, so the chart library's JavaScript actually executes; you don't install node-canvas, compile native modules, or keep a Chrome alive.
# pip install rendex
from rendex import Rendex
from pathlib import Path
rendex = Rendex("YOUR_API_KEY")
# A self-contained HTML page with Chart.js from a CDN. animation:false so the
# chart is fully drawn by the time Rendex captures it.
html = """
<!doctype html><html><head>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head><body style="margin:0;width:800px">
<canvas id="c" width="800" height="400"></canvas>
<script>
new Chart(document.getElementById('c'), {
type: 'bar',
data: {
labels: ['Q1','Q2','Q3','Q4'],
datasets: [{ label: 'Revenue (€k)', data: [482, 591, 623, 705],
backgroundColor: '#ea580c' }]
},
options: { animation: false, responsive: false,
plugins: { legend: { display: true } } }
});
</script>
</body></html>
"""
result = rendex.render_html(html, format="png", width=800, height=400,
device_scale_factor=2)
Path("chart.png").write_bytes(result.image)
print("Rendered", result.metadata)
Already produce charts as SVG (ECharts SSR, D3, a matplotlib SVG export) or as a styled HTML table? Pass that markup directly — same call, no JavaScript needed. 500 free renders/month, no card to start: get a key.
The honest trade-off: this doesn't make Chrome disappear — it relocates it onto Rendex's infrastructure. You give up nothing technically hard; you pay a per-render fee, a network hop, and you send the chart markup to a vendor (a sync render is processed transiently and not stored). You gain: no node-gyp, no libcairo/libpango in your image, no Chromium-in-CI, no font-package debugging, no kaleido-version breakage.
Method 2: Chart.js + chartjs-node-canvas (Node, no browser)
The canonical Node route renders Chart.js onto a node-canvas surface — no browser, but you inherit node-canvas's native Cairo/Pango dependency.
// npm i chartjs-node-canvas chart.js
// + system libs: apt-get install libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev
import { ChartJSNodeCanvas } from "chartjs-node-canvas";
import { writeFileSync } from "fs";
const canvas = new ChartJSNodeCanvas({ width: 800, height: 400 });
const png = await canvas.renderToBuffer({
type: "bar",
data: { labels: ["Q1","Q2","Q3","Q4"],
datasets: [{ label: "Revenue", data: [482,591,623,705] }] },
});
writeFileSync("chart.png", png);
Where it bites: node-canvas's native build fails on fresh Node majors, Alpine, and ARM until prebuilt binaries catch up; fonts must be installed in the image or text renders as boxes. For new projects, the Skia-based @napi-rs/canvas or skia-canvas ship N-API prebuilts and avoid the compile (the old canvas-prebuilt package is discontinued — don't use it).
Method 3: matplotlib savefig (Python, the lightest deps)
For Python, matplotlib with the Agg backend is the only genuinely browser-free and Cairo-free PNG path.
# pip install matplotlib
import matplotlib
matplotlib.use("Agg") # MUST be set before importing pyplot (headless)
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8, 4), dpi=150)
ax.bar(["Q1","Q2","Q3","Q4"], [482, 591, 623, 705], color="#ea580c")
ax.set_title("Revenue (€k)")
fig.savefig("chart.png", bbox_inches="tight")
plt.close(fig) # close every figure or you leak memory in a loop
Where it bites: you must force Agg before importing pyplot or it tries a GUI and crashes headless; missing system fonts silently drop glyphs (especially CJK); and you leak memory if you forget plt.close() in a loop. Great for native matplotlib charts; it won't render an HTML/CSS report or a JS charting library.
Method 4: pandas DataFrame → image (secretly headless Chrome)
The popular dataframe-image turns a styled DataFrame into a PNG — and its default backend shells out to a local Chrome, which surprises people in containers and CI.
# pip install dataframe_image (default backend needs a local Chrome)
import dataframe_image as dfi
import pandas as pd
df = pd.DataFrame({"Quarter": ["Q1","Q2","Q3","Q4"], "Revenue": [482,591,623,705]})
styled = df.style.background_gradient(subset=["Revenue"], cmap="Oranges")
dfi.export(styled, "table.png") # uses local Chrome by default
Where it bites: the default chrome backend is brittle in headless environments (it fails in Google Colab; you switch to the selenium/Firefox or matplotlib backends); the matplotlib fallback only simulates the default table style (no custom CSS, no captions). Note df2img pins the deprecated kaleido v0 line — a maintenance risk for new code. If you're already styling the DataFrame as HTML (df.style.to_html()), you've produced markup, not pixels — rendering it is the same Chrome problem, which is exactly what an HTML-render API solves (Method 1).
Method 5: Plotly — mind the kaleido break
Plotly's static export is the route most affected by 2025/26 changes. fig.to_image() uses kaleido, but kaleido v1 no longer bundles Chromium — if no compatible Chrome is on the box, export errors.
# pip install --upgrade plotly kaleido
# kaleido v1 does NOT ship a browser — install Chrome or run: kaleido_get_chrome
import plotly.graph_objects as go
fig = go.Figure(go.Bar(x=["Q1","Q2","Q3","Q4"], y=[482,591,623,705]))
fig.write_image("chart.png") # engine auto = kaleido (needs system Chrome)
Where it bites: the old engine="orca" path is deprecated and removed after September 2025 — migrate to kaleido. And kaleido v0 (bundled Chromium) is superseded by v1 (bring your own Chrome), so guides that rely on the bundled browser are stale.
Method 6: ECharts SSR — zero-dependency, but SVG not PNG
Apache ECharts can render server-side with no native dependency at all — the catch is it outputs an SVG string, not a raster.
// npm i echarts (no Cairo, no Chrome — cleanest native-free route)
import * as echarts from "echarts";
const chart = echarts.init(null, null, { renderer: "svg", ssr: true, width: 800, height: 400 });
chart.setOption({
xAxis: { type: "category", data: ["Q1","Q2","Q3","Q4"] },
yAxis: { type: "value" },
series: [{ type: "bar", data: [482,591,623,705] }],
});
const svg = chart.renderToSVGString(); // SVG markup — rasterize for email/PDF
Where it bites: if the consumer needs a PNG (email, Slack, a PDF embed), you still have to rasterize that SVG — via resvg, Sharp, or a browser. ECharts SSR gets you clean markup with no deps; turning it into pixels re-introduces a renderer. (That SVG is also a perfectly good input to POST to a rendering API — back to Method 1.)
Which one should you use?
| Approach | Heavy dependency | Output | Best for |
|---|---|---|---|
| Rendex API (HTML/SVG → PNG) | None on your box | PNG / PDF | Charts already expressible as HTML/SVG; no infra to own |
| Chart.js + node-canvas | Cairo/Pango (native) | PNG | Node apps that can carry the native build |
| matplotlib (Agg) | None (bundled C ext) | PNG | Native matplotlib charts; lightest Python deps |
| dataframe-image | Headless Chrome | PNG | Styled pandas DataFrames (if Chrome is present) |
| Plotly + kaleido v1 | System Chrome | PNG | Plotly figures (install Chrome separately) |
| ECharts SSR | None | SVG (rasterize for PNG) | Clean markup; you handle rasterization |
The honest takeaway: there is no painless "draw some pixels" path that scales — you either own a native canvas stack or a headless browser. The in-process options (matplotlib, node-canvas) work well if you accept their build and font upkeep; ECharts SSR is clean but stops at SVG. If your charts and tables are already HTML/CSS/SVG — which, for most report and dashboard work, they are — the least infra is to POST that markup to Rendex and let the browser live somewhere else. See the Python report-rendering recipe for the full pattern, or rendering an HTML table to PNG for the table case.
Top comments (0)