"react vs vue vs svelte vs solid-js — who's actually winning?" This tool answers it. Fetches daily downloads from
api.npmjs.org(CORS-enabled, no auth) and overlays up to 6 packages on one inline SVG chart. No chart library. ~300 lines of vanilla JS. The scale + tick math is pure and gets 17 unit tests.
🌐 Demo: https://sen.ltd/portfolio/npm-downloads-chart/
📦 GitHub: https://github.com/sen-ltd/npm-downloads-chart
Why DIY?
I wanted:
- Pick arbitrary packages, compare side-by-side
- Switch the time range instantly
- No D3, no Chart.js, no Recharts — write the SVG by hand
- Practise direct API consumption from the browser
That whole brief fits in 3 files, ~300 lines. As a bonus, writing it makes you understand what Chart.js is actually doing internally.
The npm API
The public endpoint takes no auth:
https://api.npmjs.org/downloads/range/<period>/<package>
<period> is last-week, last-month, last-year, or YYYY-MM-DD:YYYY-MM-DD. Response shape:
{
"downloads": [
{ "downloads": 8523912, "day": "2025-05-24" },
{ "downloads": 9213489, "day": "2025-05-25" }
],
"package": "react",
"start": "2025-05-24",
"end": "2026-05-23"
}
CORS is enabled, so the browser can fetch it directly — no proxy server.
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,
};
}
Fan-out with Promise.allSettled so one bad package name doesn't kill the chart:
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 }
);
}
The failed packages get listed in the UI's status line; the chart renders whatever succeeded.
Nice ticks (the algorithm Chart.js / D3 also use)
If your max is 8723, you don't want a y-axis labelled 0, 1500, 3000, 4500, 6000, 7500, 8723. You want 0, 2000, 4000, 6000, 8000, 10000. The trick is to snap the step to the nearest "round" fraction times a power of 10:
export function niceTicks(min, max, targetCount = 5) {
const range = max - min;
const roughStep = range / targetCount;
// What order of magnitude is this step?
const exponent = Math.floor(Math.log10(roughStep));
const fraction = roughStep / Math.pow(10, exponent);
// Snap fraction to {1, 2, 2.5, 5, 10}
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;
}
The five steps:
-
Order of magnitude via
Math.log10()— for 8723, exponent is 3 (10³ = 1000). - Target step divided by that magnitude — 8723/5 = 1744.6, divide by 1000 = 1.7.
-
Snap to nearest in
{1, 2, 2.5, 5}— 1.7 → 2. - Scale back up — 2 × 1000 = 2000.
-
Tick from 0 by step —
[0, 2000, 4000, 6000, 8000, 10000].
That gives you:
-
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]
Same algorithm used by Chart.js / D3 / Recharts under the hood, condensed to ~20 lines.
Compact count formatting
12500000 is unreadable on an axis label. 12.5M is fine.
export function formatCount(n) {
if (n < 1000) return String(n);
if (n < 1_000_000) {
const v = n / 1000;
return v >= 100 ? `${Math.round(v)}k` : `${v.toFixed(1)}k`;
}
const v = n / 1_000_000;
return v >= 100 ? `${Math.round(v)}M` : `${v.toFixed(1)}M`;
}
The rule: drop a digit of precision per decade. 999 stays as-is. 1234 → 1.2k. 123456 → 123k (the leading digit makes a decimal redundant). 234567890 → 235M. Reads cleanly on a chart axis without 0.000 clutter or 1234567 walls.
SVG line rendering
The packages span a wide range — react is 22M/day at peak, solid-js is around 200k/day. You could give each series its own y-axis, but for a comparison chart the size difference is the message. So they share one scale; svelte and solid-js show up as flat lines near the bottom, react soars above. That's the truth being conveyed.
const xScale = linearScale(0, dayCount - 1, 0, innerW);
const yScale = linearScale(0, yMax, innerH, 0); // inverted: SVG y grows down
const seriesLines = seriesList.map((series, i) => {
const color = COLORS[i % COLORS.length];
const coords = series.points.map((p, idx) => ({
x: xScale.apply(idx),
y: yScale.apply(p.downloads),
}));
return `<polyline fill="none" stroke="${color}" stroke-width="2"
points="${polylinePoints(coords)}" />`;
}).join("");
A single <polyline> with points="x1,y1 x2,y2 ..." is enough. You could go Bézier-smoothed with <path d="M ... C ..."> but daily downloads have meaningful weekly seasonality (lower on weekends); smoothing would lie about that.
Architecture
scale.js ← Pure functions: linearScale, niceTicks, formatCount … (17 tests)
chart.js ← SVG renderer (depends only on scale.js)
npm-api.js ← api.npmjs.org client (direct CORS fetch)
app.js ← UI glue: input → fetch → render
Dependency direction:
app.js → npm-api.js (fetch)
app.js → chart.js → scale.js
scale.js doesn't touch the DOM or fetch. Functions like linearScale(0, 100, 0, 500).apply(50) === 250 are unit-testable in Node:
test("niceTicks rounds awkward maxima to clean numbers", () => {
const ticks = niceTicks(0, 8723, 5);
assert.equal(ticks[0], 0);
assert.ok(ticks[ticks.length - 1] >= 8723);
// step must be one of {1, 2, 2.5, 5} × 10^k
const step = ticks[1] - ticks[0];
const exponent = Math.floor(Math.log10(step));
const frac = step / Math.pow(10, exponent);
assert.ok([1, 2, 2.5, 5].some((n) => Math.abs(frac - n) < 1e-9));
});
test("w-screen vs h-screen separation", () => {
// not from this project but the same principle: separate tests from rendering
});
Splitting the project this way means I caught two real bugs before any pixel rendered: the max === min edge case (degenerate scale) and the float-drift accumulator inside the tick generator. Both are off-by-one-y-axis-tick types of bug that are hard to spot visually but trivial to assert in Node.
Try it
- Demo: https://sen.ltd/portfolio/npm-downloads-chart/
- GitHub: https://github.com/sen-ltd/npm-downloads-chart
Paste some package names you actually use. Compare your stack against the alternatives. The chart isn't going to flatter anyone — it just shows the numbers.
Takeaways
- npm's public download API takes no auth and has CORS enabled — browsers can fetch it directly.
-
Promise.allSettledis the right primitive for multi-package fan-out: one 404 doesn't kill the chart. - The
{1, 2, 2.5, 5} × 10^ksnap is the classic nice-tick algorithm that Chart.js / D3 / Recharts all use internally — ~20 lines reimplements it. -
Compact formatting is "drop a digit of precision per decade" (
1.2k→123k→1.2M→235M). -
Pure scale math separated from SVG rendering means
node --testcan verify the boundary cases (degenerate domains, float drift, off-by-one) that are nearly impossible to spot visually.
This is OSS portfolio #242 from SEN LLC (Tokyo). We ship small, sharp tools continuously: https://sen.ltd/portfolio/

Top comments (0)