If you've ever wondered how star-history.com plots the growth curve of a repository with 200,000 stars, the answer is: it doesn't — not exactly. GitHub's stargazers API quietly caps at 40,000 reachable stargazers. Past that, you're sampling and stitching. I built a browser-only version to see what that actually looks like.
📦 GitHub: https://github.com/sen-ltd/github-star-history
🔗 Demo: https://sen.ltd/portfolio/github-star-history/
No server, no proxy. Your browser talks to api.github.com directly; if you paste in a Personal Access Token it lives only in this-device localStorage. About 500 lines of vanilla JS + hand-rolled SVG.
The undocumented Accept header
You probably know GET /repos/{owner}/{repo}/stargazers returns a list of users. What you may not know is that with one custom media type, the response shape changes:
GET /repos/{owner}/{repo}/stargazers
Accept: application/vnd.github.star+json
Instead of [{login, id, ...}] you now get [{user, starred_at}]. That starred_at timestamp is the only thing a star-history chart actually needs.
It's documented (buried in GitHub's REST API reference) but I'd never stumbled on it until I went looking. Every other stargazer-history tool out there depends on it.
Pagination: 100 per page, and a Link header does the counting for you
GET /repos/facebook/react/stargazers?per_page=100&page=1
Accept: application/vnd.github.star+json
The response includes a Link header like:
<https://api.github.com/...&page=2>; rel="next",
<https://api.github.com/...&page=2320>; rel="last"
Parsing rel="last" tells you the total page count without another request:
export function parseLastPage(linkHeader) {
if (!linkHeader) return 1;
const m = linkHeader.match(/<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"/);
return m ? Number(m[1]) : 1;
}
For facebook/react at ~232k stars, that's ~2320 pages. Which is where things get interesting.
The 40,000-stargazer wall
Try ?page=500. GitHub returns an empty array. Try ?page=1000. Still empty. Try ?page=401. Also empty.
GitHub caps the stargazers endpoint at page 400. This isn't in the public docs but it is reproducible and widely known in the star-history ecosystem. Past 40,000 stargazers, the chronological stream is simply not addressable via this endpoint.
What that means for a repo like facebook/react:
-
stargazers_countfromGET /repos/{owner}/{repo}tells you the current total: ~232,000. - Stargazers pages 1–400 give you the first ~40,000 users in chronological order.
- Everything after that is invisible to this API.
To plot a growth curve, you have three options:
- Ignore it. Show the curve up to 40k and stop. (Wrong-looking: the line plateaus in ~2015 for React.)
- Use GitHub Archive / BigQuery. Accurate, but requires a paid Google Cloud project and a batch query. Not doable from a browser.
-
Sample + anchor. Pick N evenly-spaced pages from
[1, 400], take theirstarred_attimestamps, then pin the right edge of the chart at(now, stargazers_count). The curve is interpolated through your samples.
star-history.com does option 3. This app does option 3. It's a pragmatic lie, but a small one: the shape of a cumulative distribution over hundreds of thousands of data points is extremely well-behaved, so 24 samples give you a curve that's visually indistinguishable from the truth.
The sampling strategy, in code
const HARD_PAGE_LIMIT = 400;
// Read lastPage from page 1's Link header.
const first = await fetchStargazerPage(owner, repo, 1, token);
const reachableLastPage = Math.min(first.lastPage, HARD_PAGE_LIMIT);
let pages;
if (reachableLastPage <= maxSamplePages) {
// Small repo: fetch every page, full fidelity.
pages = range(1, reachableLastPage);
} else {
// Big repo: evenly spaced samples across reachable range.
pages = evenSample(1, reachableLastPage, maxSamplePages);
}
Where evenSample(1, 400, 24) returns something like [1, 18, 35, 52, ..., 383, 400]. That's 24 API calls regardless of whether the repo has 40k or 400k stars — which matters, because unauthenticated GitHub allows just 60 requests per hour.
Now the cumulative curve. Each page P starting at index (P-1)*100+1, so for page 200 with 100 items, the first item represents stargazer #19,901:
pg.items.forEach((iso, i) => {
const starIndex = (pg.page - 1) * 100 + i + 1;
points.push({ t: new Date(iso).getTime(), n: starIndex });
});
Finally, anchor:
const last = points[points.length - 1];
if (meta.stargazersCount > last.n) {
points.push({ t: Date.now(), n: meta.stargazersCount });
}
That Date.now() anchor is load-bearing. Without it, a sampled-only curve for facebook/react ends in ~2016 at ~40k stars. With it, the curve climbs correctly to today's ~232k. The interpolation between the last sampled page (at maybe 2016) and today is a straight line — technically a lie, but one you're welcome to notice from the "(sampled)" badge in the legend.
Rate limits: the other wall
Unauthenticated: 60 requests per hour. At 24 requests per repo, that's 2 repos before you're locked out.
Authenticated with a PAT: 5000 requests per hour. Classic tokens work; fine-grained tokens work too (no special scopes needed — public read is default). The token is stored in localStorage and only ever sent to api.github.com:
function authHeaders(token) {
const h = { Accept: "application/vnd.github.star+json" };
if (token) h.Authorization = `Bearer ${token}`;
return h;
}
When the cap is hit, GitHub returns HTTP 403 with two useful headers:
x-ratelimit-remaining: 0-
x-ratelimit-reset: 1744812000(Unix seconds)
Surface both to the user:
if (res.status === 403 && Number(res.headers.get("x-ratelimit-remaining")) === 0) {
const resetSec = Number(res.headers.get("x-ratelimit-reset"));
throw new RateLimitError(new Date(resetSec * 1000));
}
So the user sees "Rate limit hit. Resets at 2026-04-24 14:40. Add a PAT above to raise the limit" instead of a vague failure.
Drawing the chart without a charting library
The chart is hand-rolled SVG. Why? Because once you're plotting 4 series × up to 100 points each, D3's 90 KB gzipped is bigger than the entire rest of the app. And the problem is genuinely small:
- Compute data extents (
minX, maxX, maxY). - Round
maxYup to a "nice" number (1, 2, 2.5, 5, 10 × 10^k). - Linear scale from data → screen coordinates.
- Emit one
<path d="M.. L.. L..">per series, plus<line>s for grid and<text>s for labels.
The "nice number" axis is the only non-obvious piece:
export function niceMaxY(max) {
if (max <= 1) return 1;
const exp = Math.floor(Math.log10(max));
const pow = 10 ** exp;
const norm = max / pow;
let nice;
if (norm <= 1) nice = 1;
else if (norm <= 2) nice = 2;
else if (norm <= 2.5) nice = 2.5;
else if (norm <= 5) nice = 5;
else nice = 10;
return nice * pow;
}
For max = 232000: norm = 2.32 → nice = 2.5 → ticks at 0, 50k, 100k, 150k, 200k, 250k. Every tick is a clean human-readable number. Without the 2.5 tier, 232k jumps to 500k and the chart wastes half its vertical space.
localStorage as a 24-hour cache
Stargazers don't change fast. A 24-hour TTL on cached curves means a second visit is instant and uses zero API calls:
const PREFIX = "gsh:v1:";
export function saveCached(owner, repo, data, store = localStorage) {
store.setItem(
`${PREFIX}${owner.toLowerCase()}/${repo.toLowerCase()}`,
JSON.stringify({ savedAt: Date.now(), data }),
);
}
export function loadCached(owner, repo, ttlMs = 24 * 3600 * 1000, store = localStorage) {
const raw = store.getItem(`${PREFIX}${owner.toLowerCase()}/${repo.toLowerCase()}`);
if (!raw) return null;
const { savedAt, data } = JSON.parse(raw);
if (Date.now() - savedAt > ttlMs) return null;
return data;
}
Prefixing the key with a schema version (gsh:v1:) means I can bump to v2: later and old entries become dead weight that the user or a "Clear cache" button can sweep.
What this app deliberately doesn't do
-
No PNG export from server.
canvas.toBlobwith anXMLSerializer-stringified SVG drawn into anImage— everything in the browser. -
No analytics, no beacon. If you open DevTools' Network tab, the only outbound traffic is to
api.github.com. - No sophisticated cache invalidation. 24-hour TTL is fine for "I want a rough curve for my blog post." If you need live precision, add a PAT and click "Clear cache."
The entire JS payload is ~9 KB minified, served as ES modules with no build step. Run it locally with:
python3 -m http.server 8080
That's the whole development setup.
Takeaway
The application/vnd.github.star+json accept header + the 400-page pagination wall + stargazers_count as a right-anchor are the three things you need to build a star-history chart. Plus a Link header parser and a PAT-friendly rate-limit error. The sampling is a lie but a defensible one — the shape of 232k timestamps sampled at 24 evenly-spaced pages is indistinguishable, to the eye, from the truth.
Source: https://github.com/sen-ltd/github-star-history
Live: https://sen.ltd/portfolio/github-star-history/
Built by SEN LLC (sen.ltd). We publish one small open-source tool like this per day as a public résumé; the full list is here.