GitHub Star History API, measured: from about 2,500 requests to 24
Development
On October 14 the GitHub engineering blog published “From 2,500 requests to 24: Rebuilding star history without the stargazers.” I called the new endpoint against my own open-source project model-usage. I wanted two things clear: how a maintainer now gets a star curve in fewer requests, and what someone who builds APIs can take from this redesign.
Why did tools like star-history.com suddenly lose their data?
To draw a star-growth curve, you used to page through the stargazer list. Each page is a batch of people who currently star the repository, plus the time they starred it. Tools kept the timestamps, dropped the identities, and added the counts up themselves.
The problem sat on both sides. Identities could be collected in bulk, so people scraped public stargazer lists for spam and phishing. The blog puts it this way:
"Identities were available for bulk collection, so bad actors started scraping public stargazer lists for spam and phishing campaigns."
GitHub’s first move was to stop the bleeding: stargazer and watcher lists were restricted to repository admins and collaborators. Callers without permission get an empty response or 403 Forbidden. The chart in the blog marks the tightening as July 22.
The side effect was that a lot of tools which only used timestamps, and never showed usernames, broke along with them. star-history.com is the clearest case. GitHub then talked to maintainers and tool authors, and found that what they cared about was the curve:
"What they cared about was the curve."
Tianzhou Chen, maintainer of star-history.com and a Bytebase co-founder, writes in the blog that when the API change landed, users rallied around the project. GitHub’s Community team connected them with product engineering, and together they built this dedicated endpoint.
"When the API change disrupted Star History, users rallied around the project. GitHub's Community team listened and connected us with product engineering, who dug into our use case and worked alongside us to design a purpose-built endpoint. A difficult situation became a constructive collaboration between GitHub, Star History, and the broader community—ultimately leading to a better solution."
On September 4, the GitHub changelog announced the new endpoint: GET /repos/{owner}/{repo}/stargazers/history. It returns star counts over time and exposes no stargazer identity.
Why was the old stargazers endpoint so expensive?
The old endpoint, GET /repos/{owner}/{repo}/stargazers, answers “who starred this repository.” Each page holds at most 100 people, so the cost of a full curve grows linearly with popularity. react/react has about 251,000 stars, which means about 2,511 requests. Tools often hit the rate limit and had to sample pages.
Worse, there was a pagination bug: every request counted every star in order to compute the total number of pages, while the caller received only one page of at most 100 people.
"Due to a bug in the pagination implementation, every request also counted all of those stars to work out how many pages existed, even though the caller received only one page of up to 100 people."
How large was the cost? The post’s numbers: in the week before access was tightened, nearly 37,000 of these count queries were slow enough to be logged, averaging close to 3 seconds each. Database protection terminated more than 1,000 of them, and that produced request timeouts. After the tightening, the database handled about 60% fewer stargazer requests, and the total time spent on count queries fell about 80%.
How do you return star counts in a way that actually saves the database? Three designs
Dropping identities solved the privacy problem. The hard part was not scanning every star in the repository on every request. The blog records three paths:
- Design:
- 1. Drop identities, keep one event per star
- Approach:
- Without collaborator access, return
starred_atand no identity - Privacy:
- Solved
- Database cost:
- The request count stays the same. Callers still page through tens of thousands of rows and aggregate them themselves
- Result:
- Dropped
- Design:
- 2. Aggregate first, then paginate
- Approach:
- Bucket every star by day or week, sort, then
OFFSET/LIMIT - Privacy:
- Solved
- Database cost:
- Every page rescans every star. A full
react/reactpull is 24 requests and about 6 million rows examined - Result:
- Dropped
- Design:
- 3. Narrow by time, then aggregate
- Approach:
- Turn
pageandper_pageinto an indexedcreated_atrange, read only those weeks, and count by day - Privacy:
- Solved
- Database cost:
- Each request handles only the weeks it returns.
react/reactis about 24 requests and about 251,000 rows across the whole history - Result:
- Shipped
The problem with design 2: the response got smaller, and the database’s work did not. Returning fewer buckets only shrinks the JSON. The grouping and sorting are already done:
"Returning fewer buckets would shrink the JSON response, but it would not reduce the expensive grouping and sorting the database had already performed."
The data flow of the design that shipped:
indexed date range -> group rows in that range -> daily buckets -> responseEach request covers at most 30 weeks. A larger page walks further back into history. The response shape matches GET /repos/{owner}/{repo}/stats/commit_activity. The official docs add the contract details: grouped by calendar week, most recent week first, and concatenating the pages produces one continuous sequence. Weeks with no stars return 0. days starts on Sunday. per_page maxes at 30, and page maxes at 100. The docs say outright that week and day boundaries are not guaranteed to align with UTC.
What limits does the new endpoint still have?
Only what the post and the docs state outright:
- Cut on calendar weeks. The page unit is a calendar week, not an arbitrary range.
- Boundaries are not guaranteed to be UTC. The docs say "Week and day boundaries are not guaranteed to align with UTC." Week and day edges are not guaranteed to line up with UTC. Leave some margin if you compare day by day.
- Exceptionally dense weeks are still slow. The time range limits which weeks one request covers. It does not limit how many stars fall inside that span. "exceptionally dense weeks remain a scaling limitation": an exceptionally dense week is still the bottleneck.
- The data model itself. The blog says the star model records the “current” set of stars, not a log of historical events. The summary also lists "removed stars" as one factor that can still change the result. The blog does not spell out how. I am not inferring it here.
A side benefit: while comparing the old and new paths, GitHub found that the GitHub.com stargazers page was also counting stars live. After they switched it to a cached count, the hundreds of slow queries that page produced each week dropped to zero.
How does an indie developer plot their own project with the GitHub Star History API?
At 16:16 on 2026-10-15 (Beijing time) I called the model-usage stargazers history endpoint with curl, and no authentication:
- Page 1 returned HTTP 200 and 30 weeks. The
Linkheader gaverel="next"andrel="last"(3 pages in total). The URL in that Link header uses the/repositories/{id}/...form. Follow it as-is. - The 3 pages cover 64 weeks. The earliest week starts on 2025-07-27 (read as UTC; the repository was created at 2025-07-27T16:10:46Z).
- The
totalvalues across those 64 weeks add up to 21. At the same time,GET /repos/evanlong-me/model-usage/stargazers/countreturned{"count": 21}, and the repository endpoint’sstargazers_countis also 21. The three agree. - The unauthenticated rate limit is 60 requests per hour (response header
x-ratelimit-limit: 60).
A small project a little over a year old gets its full curve in 3 requests.
The smallest script that works:
curl -sS -i -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/OWNER/REPO/stargazers/history?per_page=30&page=1"import json, re, urllib.request, datetime as dt
import matplotlib.pyplot as plt
url = "https://api.github.com/repos/OWNER/REPO/stargazers/history?per_page=30"
weeks = []
while url:
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
with urllib.request.urlopen(req) as r:
weeks += json.load(r)
m = re.search(r'<([^>]+)>;\s*rel="next"', r.headers.get("Link", ""))
url = m.group(1) if m else None
weeks.sort(key=lambda w: w["week"]) # 接口最近一周在前,这里改为正序
xs, ys, total = [], [], 0
for w in weeks:
for i, n in enumerate(w["days"]): # days 从周日开始
total += n
xs.append(dt.datetime.fromtimestamp(w["week"], dt.timezone.utc) + dt.timedelta(days=i))
ys.append(total)
plt.step(xs, ys, where="post"); plt.ylabel("cumulative stars")
plt.savefig("star-history.png", dpi=150)Two reminders. The curve you get by summing total is this endpoint’s view of the curve. Check the total against /stargazers/count, the way I did. page tops out at 100 and each page is 30 weeks, which works out to about 3,000 weeks of coverage. That is enough for almost every project.
What can you borrow from this redesign when you build your own API?
GitHub closes with four points. I pick two and apply them to an indie developer’s day-to-day work:
Return only what the use case needs. Their line is "Return only what the use case needs. Anything more is data someone else can misuse." Return only what the caller will use. Anything extra is data someone else can misuse. What I do: before designing an endpoint, write down what the caller will do with the data. If the answer is “draw a curve” or “build a ranking,” return the aggregate, not a detail list that carries identities. Once that list is public, it gets used for things you did not have in mind.
Narrow the range first, then aggregate. Their line is "A smaller response does not always mean a cheaper query." A smaller response is not always a cheaper query. What I do:
- Turn the pagination parameters into a range predicate on an index (a time range, or an ID range) and put it in the
WHEREclause, instead of aggregating first and then cutting withOFFSET/LIMIT. - Put a ceiling on the work one request can do (GitHub chose “at most 30 weeks”) and say in the docs what that ceiling does not control, such as dense weeks.
- For a value that gets read constantly, such as a total, use a cached count. Do not recount it on every request. That is how GitHub fixed its own stargazers page.
- Write the boundaries and the tradeoffs into the contract. Calendar weeks, non-UTC boundaries, and the maximum page are part of the API too.
What I use directly: no permission request, no token, and a few requests are enough for the full star curve of my own project. The change around a release is visible.
Sources and method
- GitHub engineering blog: Taylor Cuilty & Sarita Iyer, “From 2,500 requests to 24: Rebuilding star history without the stargazers,” page date 2026-10-14. https://github.blog/engineering/architecture-optimization/from-2500-requests-to-24-rebuilding-star-history-without-the-stargazers/
- GitHub changelog: “New API endpoint provides privacy-safe star history data,” 2026-09-04. https://github.blog/changelog/2026-09-04-new-api-endpoint-provides-privacy-safe-star-history-data/
- GitHub REST API docs, “Get repository star history,” visited 2026-10-15. https://docs.github.com/en/rest/activity/starring
- Project repository: https://github.com/evanlong-me/model-usage
Method. Measured at 2026-10-15 16:16:34 (Beijing time, UTC+8). Every call was unauthenticated, with the request header Accept: application/vnd.github+json. I followed the Link header through pages 1–3, and cross-checked against /stargazers/count and GET /repos/evanlong-me/model-usage. The cumulative curve sums each week’s days array day by day, starting on Sunday, and the time axis reads the week timestamp as UTC (the docs warn that boundaries are not guaranteed to be UTC). The react figures and the query statistics in the blog are quoted from the post. I have not reproduced them independently, and I cannot.