GitHub's Stargazers API Caps at 40,000. Here's How Star-History Tools Fake the Rest

javascript dev.to

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The response includes a Link header like:

<https://api.github.com/...&page=2>; rel="next",
<https://api.github.com/...&page=2320>; rel="last"
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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_count from GET /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:

  1. Ignore it. Show the curve up to 40k and stop. (Wrong-looking: the line plateaus in ~2015 for React.)
  2. Use GitHub Archive / BigQuery. Accurate, but requires a paid Google Cloud project and a batch query. Not doable from a browser.
  3. Sample + anchor. Pick N evenly-spaced pages from [1, 400], take their starred_at timestamps, 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);
}
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

Finally, anchor:

const last = points[points.length - 1];
if (meta.stargazersCount > last.n) {
  points.push({ t: Date.now(), n: meta.stargazersCount });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Compute data extents (minX, maxX, maxY).
  2. Round maxY up to a "nice" number (1, 2, 2.5, 5, 10 × 10^k).
  3. Linear scale from data → screen coordinates.
  4. 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;
}
Enter fullscreen mode Exit fullscreen mode

For max = 232000: norm = 2.32nice = 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;
}
Enter fullscreen mode Exit fullscreen mode

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.toBlob with an XMLSerializer-stringified SVG drawn into an Image — 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
Enter fullscreen mode Exit fullscreen mode

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.

Source: dev.to

arrow_back Back to Tutorials