Module 04: Server-Side Chart Generation

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?

Module Files

1. Why Generate Charts Server-Side?

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 Reports

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.

PDF Exports

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.

Social Sharing (Open Graph Images)

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.

JavaScript-Disabled Fallback

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.

Key Insight: Server-side rendering shines when the consumer isn't a browser — email clients, PDF generators, and social media crawlers all need a static image.
Client-Side Rendering Server-Side Rendering ======================== ======================== Browser requests page Client requests chart | | v v Server sends HTML + JS Server runs canvas code | | v v Browser executes JS Server produces PNG buffer | | v v Canvas API draws chart PNG sent to client | (or embedded in email/PDF) v User sees interactive chart Consumer sees static image

2. Node.js Approach: node-canvas

The 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.

Installation

npm install canvas

On macOS you may need Cairo and Pango headers. With Homebrew:

brew install pkg-config cairo pango libpng jpeg giflib librsvg

Basic Usage

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.

Full Bar Chart Example

See node-chart.js for a complete, well-commented bar chart that mirrors the Module 01 pageview data. The script:

  1. Creates a 600x400 canvas
  2. Defines the same pageview dataset (/, /about, /products, /blog, /contact)
  3. Computes scale factors from the data domain to pixel range
  4. Draws grid lines, bars, labels, axis titles, and a chart title
  5. Writes the result to chart.png

chartjs-node-canvas

If 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.

3. PHP Approach: GD Library

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.

Checking GD Availability

<?php
// Quick check — should print "bundled (2.1.0 compatible)"
echo gd_info()['GD Version'];
?>

Core GD Functions

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)

Basic Usage

<?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);
?>
GD Coordinate System: Unlike Canvas where 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.

4. Coordinate Mapping: The Same Problem, Two Languages

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.

5. Client-Side vs. Server-Side: Comparison

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>

6. Embedding Charts in Email

Email is the most common use case for server-side charts. There are two approaches to getting an image into an email:

Approach A: Inline Base64 Data URI

<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%.

Approach B: CID (Content-ID) Attachment

<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.

Practical Advice: For maximum compatibility, use CID attachments for production email. Use inline base64 for quick prototypes and internal tools. See email-report.js for both approaches.

7. When to Use Each Approach

The decision tree is straightforward:

Will the user interact with the chart? | +-- YES --> Client-side (Chart.js, D3, Canvas API) | +-- NO ---> Will a browser display it? | +-- YES --> Either works. Client-side is simpler. | +-- NO ---> Server-side rendering is required. | +-- Need Chart.js config reuse? --> chartjs-node-canvas +-- Simple bars/lines, no deps? --> node-canvas or GD +-- PHP-only environment? --> GD library

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.

Avoid Premature Optimization: Do not server-render charts just because you can. If the chart will be viewed in a browser by an interactive user, client-side rendering gives you hover tooltips, click handlers, and responsive resizing for free. Reserve server-side rendering for the scenarios listed above.

8. Production Considerations

Caching

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

Memory Management

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.

Font Availability

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

9. Summary