Back to blog
FILE 0x86·RENDERING A 30-DAY AWS COST CHART WITH NO CHARTING LIBRARY

Rendering a 30-day AWS cost chart with no charting library

June 9, 2026 · aws, costwatch, javascript, canvas, cost-explorer, saas

The CostWatch dashboard shows a daily spend chart for each connected AWS account. I didn't want to pull in Chart.js or any other library — the whole app is a single Lambda + a hand-rolled HTML file, and I wanted to keep it that way.

Here's how it works.


The backend: Cost Explorer with DAILY granularity

Cost Explorer's GetCostAndUsage API supports two granularities: MONTHLY and DAILY. Monthly is cheaper per call (you get a month of data in one request), but for a sparkline you need day-level detail.

resp = ce.get_cost_and_usage(
    TimePeriod={"Start": start, "End": end},  # ISO dates, up to 90 days
    Granularity="DAILY",
    Metrics=["UnblendedCost"],
    GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
)

The response comes back as ResultsByTime: one entry per day, each with a Groups array of service → cost pairs. I flatten it into two parallel lists: dates (ISO strings) and totals (daily spend floats), plus a by_service dict of the top-5 services by total spend over the window.

The endpoint is GET /account/{account_id}/cost-chart?days=N, capped at 90 days. Default is 30. The Lambda assumes the customer's cross-account role for the query, so the customer's own Cost Explorer data is used, not mine.


**The frontend: drawing on <canvas>**

No library. Just a <canvas> element and the 2D context API.

function drawSparkline(canvasId, dates, totals, bySvc) {
    const canvas = document.getElementById(canvasId);
    const ctx = canvas.getContext("2d");
    const W = canvas.width, H = canvas.height;
    const PAD = { top: 20, right: 10, bottom: 24, left: 56 };

    ctx.clearRect(0, 0, W, H);

    const maxVal = Math.max(...totals, 0.01);
    const xStep = (W - PAD.left - PAD.right) / Math.max(totals.length - 1, 1);

    function xFor(i)  { return PAD.left + i * xStep; }
    function yFor(v)  { return PAD.top + (1 - v / maxVal) * (H - PAD.top - PAD.bottom); }

    // Area fill
    ctx.beginPath();
    ctx.moveTo(xFor(0), yFor(totals[0]));
    totals.forEach((v, i) => ctx.lineTo(xFor(i), yFor(v)));
    ctx.lineTo(xFor(totals.length - 1), H - PAD.bottom);
    ctx.lineTo(xFor(0), H - PAD.bottom);
    ctx.closePath();
    ctx.fillStyle = "rgba(99, 179, 237, 0.18)";
    ctx.fill();

    // Line
    ctx.beginPath();
    ctx.strokeStyle = "#63b3ed";
    ctx.lineWidth = 2;
    totals.forEach((v, i) => {
        if (i === 0) ctx.moveTo(xFor(0), yFor(v));
        else ctx.lineTo(xFor(i), yFor(v));
    });
    ctx.stroke();

The area fill is the key visual element. Draw the line path, then extend it down to the baseline and close the path, then fill with a translucent rgba. That gives you the gradient-without-gradient look you see in most sparklines — just a flat translucent fill under the line.

For Y-axis labels I render max spend at the top and "$0" at the baseline. For X-axis I render only the first and last date tick — any more than two ticks on a 200px-wide chart looks cluttered.


Per-service breakdown as a stacked legend

The by_service payload from the API gives me the top 5 services. I render them as colored dots below the chart with the service name and total spend for the window.

const CHART_COLORS = ["#63b3ed","#68d391","#f6ad55","#fc8181","#b794f4"];

Object.entries(bySvc).forEach(([svc, vals], idx) => {
    const total = vals.reduce((s, v) => s + v, 0);
    if (total < 0.01) return;
    // dot + label
    ctx.fillStyle = CHART_COLORS[idx % CHART_COLORS.length];
    ctx.fillRect(PAD.left, legendY, 10, 10);
    ctx.fillStyle = "#e2e8f0";
    ctx.fillText(`${shortSvc(svc)}: $${total.toFixed(2)}`, PAD.left + 14, legendY + 9);
    legendY += 16;
});

shortSvc() strips the "Amazon " and "AWS " prefixes that Cost Explorer adds to every service name. "Amazon EC2-Instance" becomes "EC2-Instance". Much more readable at the 10-character widths the legend has.


The toggle pattern

The chart is collapsed by default — a "show 30-day chart" link expands it. On first expand it fires the API call and draws the canvas. On subsequent toggles it just shows/hides the already-rendered canvas (no repeat API calls).

async function toggleSparkline(accountId, canvasId) {
    const wrap = document.getElementById(`spark-wrap-${accountId}`);
    if (wrap.dataset.loaded === "1") {
        wrap.classList.toggle("open");
        return;
    }
    wrap.classList.add("open");
    const resp = await fetch(`/account/${accountId}/cost-chart?days=30`);
    const data = await resp.json();
    if (data.totals) {
        drawSparkline(canvasId, data.dates, data.totals, data.by_service);
        wrap.dataset.loaded = "1";
    }
}

Why not Chart.js?

Mostly weight. Chart.js minified is ~200KB. For a feature that shows a 30-day line with two axis labels, that's a lot of bytes for what you get. The canvas 2D API is available everywhere, the code is ~60 lines, and I control every pixel.

The tradeoff is that I can't easily add tooltips. If you hover a data point there's no tooltip — just the line. Tooltip support with raw canvas means implementing hit detection on a mousemove listener, which adds another 40-50 lines. I'll add it when someone asks for it.


CostWatch is at costwatch.dev — $5/mo Solo, $15/mo Team. Connect your AWS account in two clicks (SAR CloudFormation template, no manual IAM fiddling).