Maintainers watch star history to answer one question: did a launch, release, or event move the project's trajectory? Reconstructing that curve through the GitHub API used to mean paging through stargazer records — every current stargazer and the timestamp of their star — keeping the dates and discarding the identities.
That design caused problems at both ends. Because identities were available in bulk, attackers scraped public stargazer lists for spam and phishing. And on heavily starred repositories the endpoint could force the database to process thousands of entries to produce a single page.
The obvious fix — return counts instead of people — did not make the query any cheaper on its own. Serving it inexpensively required reducing how much data the database examined, not just how much it shipped back.
Access restrictions and what tools still needed
The first step addressed both the privacy risk and the database load: stargazer and watcher lists were restricted to repository administrators and collaborators. Callers without the required permission would get an empty response or a 403 Forbidden.
That cutoff broke tools that had used the old response to compute star history — frequently without ever displaying the usernames it returned. Conversations with maintainers and tool builders showed the same requirement: star counts over time, as an approximate signal of interest. Comparing activity before and after a release, or checking whether an announcement reached new developers, only needs the curve.
Identity had looked like part of the contract because every timestamp came attached to a user. Once it was removed from the requirement, an aggregate endpoint for the real job — changes in star counts over time at useful resolution — became the target.
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.
Tianzhou Chen, star-history.com maintainer and Bytebase co-founder
Why the old endpoint was expensive
GET /repos/{owner}/{repo}/stargazers was built to answer "who starred this repository?" — a paginated list of people, with a starred_at value appended per entry when the request asked for a custom media type.
The star model behind it was designed for the same question. It tracks the current set of stargazers, which keeps either a count or a list simple, and removes the record when someone unstars. It is not an event log, so treating the list as a time series means retrieving individual identities and processing every current star. Pages cap at 100 people, so the cost scales with popularity: the full curve for react/react, at roughly 251,000 stars, took about 2,511 requests, pushing tools into rate limits or forced sampling.
Answering a page also meant querying the repository's current stars, and a pagination bug caused every request to count all of them to determine the page count — even though the caller received at most one page of 100 people. On a highly starred repository that is thousands of entries counted per request. In the week before access was restricted, nearly 37,000 of those count queries ran slowly enough to be logged, averaging nearly 3 seconds each, and database safeguards killed more than 1,000 of them, contributing to request timeouts.
The effect of the restriction is visible in the database load: about 60% fewer stargazer requests served, and roughly an 80% drop in total time spent on the count query.
Two designs that didn't hold up
Events without identities
A first spike kept one record per star and returned starred_at without the stargazer's identity to callers below collaborator access. It closely matched what tool builders had asked for and closed the identity-harvesting path with a relatively small change to serialization and authorization. But consumers would still have to page through and process thousands of individual records to build an aggregate history, and the use case would stay tied to an endpoint and pagination model meant for listing people rather than retrieving trends.
Aggregating before paginating
Grouping stars into daily or weekly buckets, sorting them, and paginating the result looked like the obvious next step. The problem was ordering. In a plain SQL query, the database finds all stars for the repository, groups and sorts them into buckets, and only then applies LIMIT and OFFSET:
all matching stars -> bucket every row by day or week -> sort every bucket -> OFFSET/LIMIT -> response
An anonymous request could therefore scan and aggregate thousands of index entries to return a handful of buckets. A smaller JSON response does not undo grouping and sorting the database already performed. Read that way, react/react would have required examining roughly 6 million rows, since each of the 24 requests would have reprocessed all of its stars.
Filtering before counting
The shipped design paginates star history by calendar week. Each request translates page and per_page into an indexed created_at range, filters stars to those weeks, and only then groups the matching records into daily counts. Increasing page moves backward through history.
indexed date range -> group rows in that range -> daily buckets -> response
Because the date range is applied first, a request processes only the weeks it returns rather than every current star. One request covers up to 30 weeks regardless of repository size: for react/react, about 24 requests instead of about 2,511, with each star examined roughly once — around 251,000 rows — across the whole history. Week-based ranges also match an existing convention; the response follows the shape of the weekly commit activity endpoint, GET /repos/{owner}/{repo}/stats/commit_activity.
The tradeoff is baked into the contract: the time range is bounded, the number of stars inside it is not. An exceptionally dense week can still produce a slow, expensive query, so dense weeks remain a scaling limitation even though the normal path no longer queries every current star.
Comparing old and new paths also exposed other places counting stars on demand. The repository stargazers page on GitHub.com carried the same pattern and was switched to the repository's cached star count; slow queries from that page, previously hundreds per week, fell to zero. A similar fix has since shipped for other stargazer API endpoints.
Takeaways
- A smaller response does not guarantee a cheaper query.
LIMITandOFFSETshrank the result set but not the grouping and sorting the database had already done; the order of operations had to change. - Start from what people are trying to do. Maintainers needed trend comparison — the identity list was just how the old endpoint happened to deliver the underlying data.
- Return only what the use case requires. Anything extra is data someone else can misuse.
- State the remaining limits. Calendar boundaries, removed stars, and unusually dense weeks all affect what the endpoint returns, and they are part of the API contract too.
The restriction solved the immediate problem. Listening to maintainers produced something better than what came before: star history that is easier to retrieve, cheaper to serve, and safer for everyone who stars a project.



