When you look at the download numbers for React, Vue, Svelte, and Solid.js, the raw numbers tell a story. React sits at roughly 22 million downloads per day at its peak. Vue trails behind in the single-digit millions. Svelte and Solid.js are orders of magnitude smaller, often measuring in the hundreds of thousands. But simply staring at these figures on the npm website gives you no context — no side-by-side view, no way to see trends over time, no ability to quickly change the date range. The standard npm download comparison tools exist, but they are often heavyweight, require third‑party libraries, or do not let you pick arbitrary packages. That is where a custom, lightweight solution shines.

The Limits of Existing npm Charting Tools
Popular websites like npm-stat.com or npmcharts.com are great, but they have constraints. You cannot always compare exactly the packages you want. The time range may be limited to preset periods. The visual design might not match your personal taste or the branding of your project. More importantly, these tools rely on server‑side proxies or bundled libraries. If you want to embed a chart on your own blog or portfolio, you often end up adding 50 kB of JavaScript for a single line chart.
Another challenge is understanding what happens under the hood. When you see a polished chart on a blog post, you do not see the API calls, the error handling when a package name is misspelled, or the algorithm that chooses axis labels. Building your own chart from scratch forces you to learn these details. It is a practical exercise in web development that pays off every time you debug a chart later.
The npm public API is remarkably simple and CORS‑enabled. That means you can call it directly from the browser without any backend proxy. Yet many developers shy away because they assume they need a charting library. In reality, a few dozen lines of SVG markup can produce a functional, attractive line chart. The trick is in the math — specifically, the tick‑generation algorithm that produces clean, round numbers on the y‑axis. This same algorithm powers Chart.js, D3, and Recharts. Once you understand it, you can replicate it in about 20 lines of code.
Building Your Own npm Download Comparison Tool
The goal is a single‑page application that lets you enter any npm package name, select a time range (last week, last month, last year, or custom dates), and see up to six packages plotted simultaneously. The chart should be a pure inline SVG — no canvas, no extra libraries. The entire functionality fits in three files totaling fewer than 300 lines of vanilla JS.
Getting Data from the npm API
The endpoint is public and requires no authentication:
https://api.npmjs.org/downloads/range//
Where can be last-week, last-month, last-year, or a custom range like 2024-01-01:2025-01-01. The response is a simple JSON object containing an array of downloads, each with a day and a downloads count. Because CORS headers are set, you can use the browser’s native fetch without any proxy.
A typical fetch function looks like this:
const BASE = "https://api.npmjs.org/downloads/range";
export async function fetchDownloads(packageName, period = "last-month") {
const url = `${BASE}/${encodeURIComponent(period)}/${encodeURIComponent(packageName)}`;
const res = await fetch(url);
if (!res.ok) {
if (res.status === 404) throw new Error(`Package not found: ${packageName}`);
throw new Error(`npm API ${res.status}: ${packageName}`);
}
const json = await res.json();
return {
name: packageName,
points: json.downloads,
start: json.start,
end: json.end,
};
}
Notice how the function handles 404 errors specifically — that is crucial when a user types a package name wrong.
Fan‑out with Promise.allSettled
When comparing multiple packages, you want to fetch all of them in parallel. But what if one fails? Using Promise.all would reject the entire batch. Instead, Promise.allSettled lets each fetch complete independently. Failed packages are listed in the UI’s status line, while the chart renders only the successful ones. The code is concise:
export async function fetchMany(packageNames, period = "last-month") {
const results = await Promise.allSettled(
packageNames.map((n) => fetchDownloads(n, period))
);
return results.map((r, i) =>
r.status === "fulfilled"? { ok: true, series: r.value }: { ok: false, name: packageNames[i], error: r.reason.message }
);
}
This pattern is invaluable for any dashboard that sources data from multiple endpoints. It prevents a single outage or typo from breaking the entire page.
The Secret Sauce: Nice Ticks
If your maximum download count is 8,723, you do not want a y‑axis labelled 0, 1,500, 3,000, 4,500, 6,000, 7,500, 8,723. That is messy. You want clean increments like 0, 2,000, 4,000, 6,000, 8,000, 10,000. The algorithm that produces these “nice” numbers is called the nice ticks algorithm. It is the same logic inside Chart.js, D3, and Recharts, often condensed to about 20 lines.
How It Works
Start with the data range (max – min) and a desired target number of ticks (commonly 5 or 6). Compute a rough step: range / targetCount. Determine the order of magnitude of that step using Math.log10. For 1,744.6, the exponent is 3 (10³ = 1,000). Divide the rough step by 10^exponent to get a fraction — here 1.7446 → ~1.7.
Now snap that fraction to the nearest value from the set {1, 2, 2.5, 5, 10}. These are the “nice” fractions that produce human‑readable numbers. 1.7 snaps to 2. Multiply back up: 2 × 1,000 = 2,000. That becomes your step.
Finally, generate ticks from floor(min / step) * step to ceil(max / step) * step, stepping by the nice step. The result for the example above is [0, 2000, 4000, 6000, 8000, 10000].
Here is the implementation:
export function niceTicks(min, max, targetCount = 5) {
const range = max - min;
const roughStep = range / targetCount;
const exponent = Math.floor(Math.log10(roughStep));
const fraction = roughStep / Math.pow(10, exponent);
let nice;
if (fraction <= 1) nice = 1;
else if (fraction <= 2) nice = 2;
else if (fraction <= 2.5) nice = 2.5;
else if (fraction <= 5) nice = 5;
else nice = 10;
const step = nice * Math.pow(10, exponent);
const ticks = [];
const start = Math.floor(min / step) * step;
const end = Math.ceil(max / step) * step;
for (let v = start; v <= end + step / 2; v += step) {
ticks.push(Math.round(v / step) * step);
}
return ticks;
}
A few test cases demonstrate its power:
niceTicks(0, 47, 5)→ [0, 10, 20, 30, 40, 50]niceTicks(0, 8723, 5)→ [0, 2000, 4000, 6000, 8000, 10000]niceTicks(0, 12345, 6)→ [0, 2500, 5000, 7500, 10000, 12500]
This algorithm is so robust that it is used in nearly every popular charting library. Writing it yourself demystifies those libraries and gives you full control.
Formatting Download Numbers Compact
Numbers like 12,500,000 are unreadable on an axis label. A compact format function makes the chart clean:
You may also enjoy reading: Bill Cassidy’s Loss Is RFK Jr.’s Legacy.
- Values below 1,000 display as‑is: 999 → "999"
- Values below 1,000,000 display with "k", showing one decimal only when under 10k: 1,234 → "1.2k", 123,456 → "123k"
- Values 1,000,000 and above display with "M", rounding to the nearest whole million: 234,567,890 → "235M"
export function formatCount(n) {
if (n < 1000) return String(n);
if (n < 1_000_000) {
const k = n / 1000;
return k < 10? k.toFixed(1) + "k": Math.round(k) + "k";
}
const m = n / 1_000_000;
return Math.round(m) + "M";
}
This keeps the axis clean and prevents overlapping labels. The same formatting applies to tooltips when hovering over data points.
Drawing the SVG Chart by Hand
With data and ticks ready, the chart itself is an inline SVG element. No D3, no Chart.js — just JavaScript string concatenation to build the SVG markup. The key parts are:
- Axes: Draw horizontal gridlines at each nice tick value. The left axis labels use the
formatCountoutput. - Lines: For each package, translate the download points to SVG coordinates (x = day index, y = scaled value). Use a
element with a unique stroke color. - Legend: Show each package name with its color so users can match lines.
- Tooltip: A simple
divthat appears on mouseover, showing the exact day and download count. Since it is HTML overlaid on the SVG, no complex SVG‑based tooltip is needed.
SVG is declared inside the HTML as a placeholder, then the JavaScript populates it. The small size — about 100 lines for the rendering logic — keeps the page fast. Because no canvas is used, the chart is fully accessible and can be styled with CSS.
Testing the Math
Any serious implementation should include unit tests. The nice ticks function and the format function are pure functions — they take input and produce output deterministically. That makes them ideal candidates for testing. In the GitHub repository at sen-ltd/npm-downloads-chart, the scale and tick math has 17 unit tests. These cover edge cases like negative values (though npm downloads are never negative, the algorithm should handle it), zero range, very large numbers, and different target counts.
Testing these functions gives you confidence that when React peaks at 22 million and Solid.js at 200,000, the chart will produce clean, comparable axis labels. Without tests, you risk the chart displaying a messy y‑axis or mis‑scaled data.
Putting It All Together in 300 Lines
The entire application — HTML, CSS, and JavaScript — fits in three files totaling fewer than 300 lines. Here is a rough breakdown:
- fetch.js (~25 lines): The
fetchDownloadsandfetchManyfunctions. - math.js (~25 lines): The
niceTicksandformatCountfunctions. - chart.js (~250 lines): SVG rendering, event handling for time‑range switches, updating the legend, and handling tooltips.
That compactness is the payoff. You gain a deep understanding of how charting libraries work internally. You can customize every pixel. And you avoid the dependency bundle — no need to load 50 kB of Chart.js when 3 kB of your own code does the job.
The demo at sen.ltd/portfolio/npm-downloads-chart/ allows you to add packages like react, vue, svelte, solid-js, or any other npm package. The time range instantly switches between last week, last month, last year, or custom dates. Each series gets a distinct color. Failed packages appear in the status bar, so you can correct misspellings without reloading the page.
Why This Matters for npm Download Comparison
When you build your own npm download comparison tool, you are no longer limited by third‑party services. You can embed it in a blog post, add it to a company dashboard, or use it to teach API consumption to students. The skills you learn — direct API calling, error‑resilient promise handling, rounding algorithms, SVG generation — are transferable to any web project.
Moreover, you become aware of the pitfalls. Most existing comparison tools do not handle the case where a package is unpublished or renamed. Their tick labels might cut off for high‑download packages. They might re‑fetch the entire dataset every time you change a single package. By building from scratch, you control these details. For instance, you could add caching with the Cache API to avoid repeated network calls for the same package in the same period.
The fact that the entire tool runs in the browser without any backend makes it trivial to deploy on a static site like GitHub Pages or Netlify. You can even fork the repository, change the logo, and host your own private version.
Ultimately, the 300 lines of vanilla JS represent a philosophy: that understanding the fundamentals beats relying on a black‑box library every time. The next time a coworker asks how Chart.js decides where to put gridlines, you can show them the nice ticks algorithm in 20 lines. That is the true value of building your own npm download comparison chart.
Dive into the code on GitHub, play with the demo, and next time you need to compare download trends, you will have a tool that is yours — no dependencies, no mysteries, just clean data and a clean chart.






