Every chart we have drawn so far runs in a browser. The user loads the page, JavaScript executes, and the Canvas API paints pixels on screen. That works perfectly when a human is staring at a browser window — but what happens when the consumer is not a browser at all?
sample-output.png — (generated by running node node-chart.js)Client-side charts are interactive and responsive, but they depend on a full browser environment: a DOM, a JavaScript engine, and a user who can see the screen. Several real-world scenarios break one or more of those assumptions:
Email clients strip <script> tags entirely. Gmail, Outlook, and Apple Mail will never execute your Chart.js code. If you want a chart in an email, you must send a static image — a PNG or JPEG rendered on the server before the email is assembled.
When a manager clicks "Export to PDF," the server needs to generate a pixel-perfect chart image to embed in the document. Libraries like Puppeteer can headlessly render a page, but that is heavyweight. A direct server-side Canvas render is far more efficient for simple charts.
When someone shares a dashboard link on Slack, Twitter, or LinkedIn, the platform's crawler fetches the URL and looks for an og:image meta tag. Crawlers do not execute JavaScript. To show a chart preview, the server must pre-render the image and serve it at a static URL.
Accessibility guidelines and progressive enhancement suggest providing a <noscript> fallback. A server-rendered chart image inside a <noscript> block ensures every user sees something meaningful, even with scripts disabled.
node-canvasThe node-canvas package provides a near-complete implementation of the HTML5 Canvas API that runs in Node.js without a browser. It is backed by Cairo, the same graphics library used by GTK and Firefox's rendering engine.
npm install canvas
On macOS you may need Cairo and Pango headers. With Homebrew:
brew install pkg-config cairo pango libpng jpeg giflib librsvg
const { createCanvas } = require('canvas');
const fs = require('fs');
// Create a 600x400 canvas — same API as the browser
const canvas = createCanvas(600, 400);
const ctx = canvas.getContext('2d');
// Draw on ctx exactly like browser code
ctx.fillStyle = '#f5f5f5';
ctx.fillRect(0, 0, 600, 400);
ctx.fillStyle = '#16a085';
ctx.fillRect(50, 100, 80, 200);
// Export to PNG buffer and write to disk
const buffer = canvas.toBuffer('image/png');
fs.writeFileSync('chart.png', buffer);
console.log('Saved chart.png');
The key insight is that createCanvas() returns an object with the same getContext('2d') method you used in Module 01. Every fillRect, lineTo, fillText, and arc call works identically. The only difference is the final step: instead of the browser displaying the canvas, you call canvas.toBuffer('image/png') to get a binary PNG buffer.
See node-chart.js for a complete, well-commented bar chart that mirrors the Module 01 pageview data. The script:
/, /about, /products, /blog, /contact)chart.pngIf you do not want to hand-draw every rectangle, chartjs-node-canvas wraps Chart.js to run on node-canvas. You pass the same Chart.js configuration object you would use in a browser, and it returns a PNG buffer:
const { ChartJSNodeCanvas } = require('chartjs-node-canvas');
const renderer = new ChartJSNodeCanvas({ width: 600, height: 400 });
const config = {
type: 'bar',
data: {
labels: ['/', '/about', '/products', '/blog', '/contact'],
datasets: [{
label: 'Pageviews',
data: [1245, 832, 1687, 956, 412],
backgroundColor: '#16a085'
}]
},
options: {
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Pageviews' } }
}
}
};
async function generate() {
const buffer = await renderer.renderToBuffer(config);
require('fs').writeFileSync('chartjs-output.png', buffer);
console.log('Saved chartjs-output.png');
}
generate();
This is the approach used in email-report.js to generate an analytics chart for an email report.
PHP ships with the GD library built in on most distributions. GD provides low-level image creation functions — it has been part of PHP since PHP 4 and requires no Composer packages.
<?php
// Quick check — should print "bundled (2.1.0 compatible)"
echo gd_info()['GD Version'];
?>
| Function | Purpose | Canvas Equivalent |
|---|---|---|
imagecreatetruecolor(w, h) |
Create blank image | createCanvas(w, h) |
imagecolorallocate($img, r, g, b) |
Define a color | ctx.fillStyle = 'rgb(...)' |
imagefilledrectangle($img, x1, y1, x2, y2, $c) |
Draw filled rectangle | ctx.fillRect(x, y, w, h) |
imageline($img, x1, y1, x2, y2, $c) |
Draw a line | ctx.lineTo() + ctx.stroke() |
imagestring($img, font, x, y, str, $c) |
Draw text (built-in font) | ctx.fillText(str, x, y) |
imagepng($img) |
Output PNG to browser | canvas.toBuffer('image/png') |
imagepng($img, 'file.png') |
Save PNG to file | fs.writeFileSync() |
imagedestroy($img) |
Free memory | (garbage collected) |
<?php
// Create a 600x400 truecolor image
$img = imagecreatetruecolor(600, 400);
// Allocate colors (GD uses integer color handles, not CSS strings)
$bg = imagecolorallocate($img, 245, 245, 245);
$teal = imagecolorallocate($img, 22, 160, 133);
$black = imagecolorallocate($img, 51, 51, 51);
// Fill background
imagefilledrectangle($img, 0, 0, 599, 399, $bg);
// Draw a single bar
imagefilledrectangle($img, 50, 100, 130, 350, $teal);
// Add a label
imagestring($img, 4, 60, 355, '/', $black);
// Output as PNG to browser
header('Content-Type: image/png');
imagepng($img);
imagedestroy($img);
?>
fillRect(x, y, width, height) takes a width and height, GD's imagefilledrectangle() takes two corner coordinates: (x1, y1, x2, y2). This is a common source of bugs when porting code between the two.
See php-chart.php for the complete bar chart implementation with grid lines, axis labels, and a title.
Whether you use Node.js or PHP, the fundamental challenge is identical to Module 01: mapping data values to pixel positions. Here is the scale function in both languages side by side:
// --- Node.js / JavaScript ---
function scaleY(value, maxValue, chartTop, chartBottom) {
return chartBottom - (value / maxValue) * (chartBottom - chartTop);
}
// --- PHP ---
function scaleY($value, $maxValue, $chartTop, $chartBottom) {
return $chartBottom - ($value / $maxValue) * ($chartBottom - $chartTop);
}
The logic is character-for-character identical. The only differences are PHP's $ variable prefix and function parameter syntax. If you understood Module 01's scale math, you already understand server-side chart rendering.
| Criterion | Client-Side (Browser) | Server-Side (Node/PHP) |
|---|---|---|
| Interactivity | Full — hover, click, zoom, pan | None — static image output |
| Data Size | Limited by client memory and bandwidth | Can process large datasets without sending to client |
| Dependencies | Browser + JavaScript | Node.js + canvas package, or PHP + GD |
| Output Format | Live Canvas / SVG in DOM | PNG, JPEG, or PDF image file |
| Latency | Instant updates after initial load | New request per render (can be cached) |
| Use Cases | Dashboards, exploratory analysis, real-time monitors | Email reports, PDF exports, OG images, thumbnails |
| SEO / Crawlers | Invisible to most crawlers | Fully visible as an <img> tag |
| Accessibility | Requires ARIA labels on canvas | Can include alt text on <img> |
Email is the most common use case for server-side charts. There are two approaches to getting an image into an email:
<img src="data:image/png;base64,iVBORw0KGgo..." alt="Daily pageviews" />
The entire PNG is base64-encoded and embedded directly in the src attribute. This makes the email self-contained — no external requests needed. However, Gmail strips data URIs in most cases, and the encoded image inflates the email size by ~33%.
<img src="cid:daily-chart" alt="Daily pageviews" />
The image is attached to the email as a MIME part with a Content-ID header. The <img> tag references that ID. This is better supported across email clients and keeps the HTML clean, but requires a proper MIME multipart email structure.
The decision tree is straightforward:
In practice, most analytics dashboards use both: client-side rendering for the interactive dashboard that humans use, and server-side rendering for the scheduled email digest that goes out every morning. The same data, two rendering paths.
Server-side chart generation is CPU-intensive. For charts that update on a schedule (daily reports, hourly snapshots), generate the image once and cache it. Serve the cached PNG on subsequent requests until the data changes.
// Example: Cache chart PNG with a timestamp-based filename
const filename = `chart-${new Date().toISOString().slice(0, 10)}.png`;
if (!fs.existsSync(filename)) {
const buffer = await renderer.renderToBuffer(config);
fs.writeFileSync(filename, buffer);
}
// Serve the cached file
Each canvas allocation consumes memory proportional to width * height * 4 bytes (RGBA). A 1200x800 chart uses ~3.8 MB. In PHP, always call imagedestroy() when done. In Node.js, let the garbage collector handle it, but avoid creating canvases in tight loops without releasing references.
Server environments may not have the same fonts as your development machine. Node-canvas supports registerFont() for custom fonts. GD's imagestring() uses built-in bitmap fonts (sizes 1-5), while imagettftext() supports TrueType fonts if available.
// Node.js: Register a custom font before drawing
const { registerFont } = require('canvas');
registerFont('path/to/Roboto-Regular.ttf', { family: 'Roboto' });
// Now ctx.font = '14px Roboto' will work
<noscript> fallbacks.node-canvas (low-level, same API as browser Canvas) and chartjs-node-canvas (high-level, reuses Chart.js configs).imagecreatetruecolor(), imagefilledrectangle(), and imagepng().