Cost of a sweep
Measured on 2026-09-11 with the real binary against the live API, every family
switched on and every request logged with the x-ratelimit-resource and
x-ratelimit-used headers of its own response. Cold is the first sweep of
a fresh install or of a restarted service: an empty ETag cache, a month of
workflow runs. Steady is the third sweep of the same process a few minutes
later, when about four fifths of its requests were answered 304 and cost
nothing; the second sweep is the one that pays the fill-in the actions row
describes. A 304 is free, and it is only ever available to REST: GraphQL
carries no ETag, so its column is the same in both sweeps.
The figures are per repository where the family asks per repository, and per
sweep where it asks about the account. core is the REST bucket, pt a
GraphQL point. Families with a request that is not core say which bucket it
went to: search (30 a minute), webhook_deliveries (500 a minute) and
dependency_sbom (100 a minute) each have their own. A row the round of
2026-09-11 changed describes the requests the code makes now and says what the
two sweeps measured where that differs. There is no total row on purpose: a
total belongs to one account and ages with it, so multiply the per-repository
rows by the number of repositories -list prints and you have your own.
Per family
Section titled “Per family”| Family | Cadence | Cold | Steady | Notes |
|---|---|---|---|---|
| account | 12h | 1 pt | 1 pt | one query: the calendar, the totals, the pins, the sponsors block and the star lists; 66 KB, never compressed, never conditional |
| achievements | 24h | 1 page off budget, 1 pt, plus the co-authored walk | the same | the badges come from the public profile page, read without the token and charged to no bucket. The progress rows beside them do come from the API: one counts query, and a walk over the merged pull requests whose co-authored commits no count exposes. Search stops at a thousand results, so the walk asks the whole life of the account and splits any range that overflows into two, one point per page and one per split, which is tens of points on a long-lived account and one on a new one. All of it once a day |
| totals | 12h | 1 core, 1 search, 3 pt | 0 core, 1 search, 3 pt | the profile is a 304 from the second sweep on; ten of the eleven counts are one GraphQL query of ten aliases, 406 bytes at cost 1, measured identical to the REST answers of the same minute; only the commit count is still a search, charged every time; a refused counts query is asked again one count at a time, ten points on that sweep only, so one failed search loses one number as it did in REST |
| ratelimit | 15m | 1 pt | 1 pt | GET /rate_limit is free; the point is the GraphQL half of the same question |
| events | 30m | 3 core | 1 core | before the round: three pages of a feed that changes every time, so If-None-Match never matches. Now: the walk stops at the page that carries the newest event of the previous sweep, remembered as last_event in the state file, which is usually the first page: 1 core a sweep, 96 a day fewer; a first sweep and a backfill still read the three |
| notifs | 30m | 20 core | 1 core | before the round: twenty pages of fifty; one new notification shifts every page, so the ETag never matches, for six or seven rows that were new. Now: since= the newest updated_at seen minus two cadences (last_notified in the state file), which is one page for a two hour window: 1 core a sweep, and the twenty once a day (last_full), on a backfill, and on the first sweep after an upgrade, read threads included (all=true): that bounds to a day whatever GitHub’s filter left out of the windowed reads, and it is the read that closes a thread, since a sweep lists unread threads only and a thread read without a reply leaves that listing rather than coming back with its unread tag changed. 912 core a day fewer |
| billing | 6h | 2 core | 1 core | one per month walked; the month in progress changes, the previous one is a 304 |
| profile | 12h | 4 core plus 1 per package | 0 core | the profile, the social accounts, the gists, the packages and one page of versions per package; all 304 after the first sweep |
| outbound | 12h | 8 pt | 8 pt | all GraphQL: the starred list is one query of a hundred (13.5 KB against 617 KB decompressed from REST), the five searches one point each (7 KB against 100 KB), the two comment walks one each; nothing here has an ETag on either path, so the two columns are the same |
| history | off | 1 pt per past year | the same | the account’s whole calendar, once, at a point per year it has existed |
| keys | 24h | 2 core | 0 core | the SSH and the GPG keys |
| traffic | 6h | 4 core per repo | 0 core | views, clones, referrers, paths; the fourteen-day window is a 304 until it moves |
| repo | 1h | 3 core per repo, 2 pt per 10 repos | 0 core for a repo that did not change, 2 pt per 10 repos | the repository, its community profile and one page of releases each; languages, topics, rulesets and branch protection ride in one GraphQL query per ten repositories |
| branches | 24h | 1 pt per 14 repos | 1 pt per 14 repos | one query per fourteen repositories |
| stars | 6h | 1 core per repo | 1 pt per 10 repos | the full walk through REST the first time a repository is seen, then the newest hundred of every repository in one GraphQL query per ten, about a kilobyte per repository; a restart with a state file starts at that query. Before the round the last page was asked of every repository on every sweep, all but one of them 304 |
| issues | 1h | up to 8 pt per repo | 1 to 2 pt per repo | before the round: a page of fifty with ten review threads each, 8 points, which the gateway refused with a 502 once a sweep on the busiest repository. Now: 2 points of GraphQL per repo for what changed in two cadences, ten at a time (1 point where the repository holds five or fewer), and once a UTC day a whole page sized to the repository from the last totals (5, 10, 20 or 50 cost 1, 2, 3 or 8). The sweep that carries the daily page is the expensive one, and the busiest repositories still draw the gateway’s 502 or 504 at fifty once a day and are retried at twenty-five |
| issueevents | 1h | several pt per repo for the month, plus 1 core per stacked pull request in it | 1 pt per repo, 0 to 1 core | before the round: one page of a hundred events each, up to a megabyte per repository because every event embeds its whole issue; a 304 on all but the repository that moved. Now: one GraphQL query per repository, the timeline of the ten most recently updated issues and ten pull requests for the events of the last two cadences (1 pt, 2 to 6 KB, a second page only when more than ten items moved), plus one core per pull request in a stack, whose added_to_stack event the timeline cannot name; thirty days on a first sweep, once, which is minutes rather than seconds where the pull requests are nearly all stacked, because almost every one updated in the month takes the per-issue road for its added_to_stack; a steady sweep is a point per repository and one such read at most. And from a cadence before the last run after a gap, so a stopped process does not leave its hours out of the series. Measured against the list over a week of two repositories: every event of every type agrees, field for field, except a commit referencing an issue nobody has touched, 3 of 2,217, which does not move the issue and so is not asked for. A backfill walks /issues/{n}/events per item instead of the list, 1.4 KB compressed per twelve events against 45 KB per event |
| actions | 15m | a month of runs per repo, plus 1 core per run | 1 core per repo that had a run, plus 1 per run completed since | before the round: pages of a hundred runs reaching a month back, a job list per run, and the caches and workflows of every repository, which was three fifths of the cold sweep’s bytes, and those job lists again on every sweep, nearly all of them 304. Now: pages of 30, one on a quiet repository and up to 7 while they come full of runs newer than the window, plus one per run not yet expanded, jobs listed once per attempt, at most 20 new runs a sweep; pages of 100 on the first sweep and in a backfill. Measured over three sweeps of one process, the second is the one that pays the fill-in, because the page of thirty is a new URL for every repository and holds runs the first sweep’s twenty did not cover; from there a sweep costs one page per repository that had a run and one job list per run completed since, so what it costs is how many runs the account completes |
| artifacts | 1h | up to 5 core per repo | 0 to 5 core per repo | a busy repository fills its pages, and all five of them are charged again whenever an artifact was added or expired: 5 on one sweep, 0 on the next |
| security | 1h | 2 core per repo | 0 core | Dependabot and code scanning alerts; most are the 403 of a repository with Dependabot switched off or the 404 of one without code scanning, and a refusal carries no ETag, so each was charged again on every sweep before the round remembered them. Now: a refusal is answered from memory for a day, per family, repository and endpoint, so the steady figure is 0 core on the second sweep and one request per refusal once a day. The rest are conditional requests, all 304 |
| stats | 12h | 3 core per repo | 0 core | participation and the punch card; GitHub recomputes them slowly and answers 304 |
| discussions | 2h | 2 pt per repo with a forum | the same | before the round: fifty threads with twenty comments and twenty replies each, 11 points, asked of every repository, including every one with no forum. Now: 2 points per repo with a forum, the ten most recently updated threads with the same twenty comments and twenty replies each (comments come oldest first, so a shorter page there would stop recording the eleventh comment of a thread); a repository whose forum is off is never asked |
| commits | 1h | 1 pt per repo | 1 pt per repo | the last two cadences of the default branch; a month back on the first sweep, which is a few hundred kilobytes for a busy repository |
| activity | 30m | 2 core per repo | 0 to 2 core per repo | the repository log, a hundred entries per page; charged only for a repository whose log moved, 2 on one sweep and 0 on the next |
| analyses | 6h | 1 core per repo | 0 core | most are the 403 and 404 of repositories without code scanning, charged again each sweep before the round; now remembered for a day, so the steady sweep is conditional requests and 0 core |
| forks | 12h | 1 core per repo | 1 pt per 10 repos | one page each through REST on the first sweep of a fresh install and in a backfill, then the newest hundred of every repository in one GraphQL query per ten, under a kilobyte per repository; a repository the batch reports holding more than a hundred forks is walked through REST as well, since a fork row’s stars and days_since_push move and the batch cannot refresh the rows past its page. Before the round: one request per repository a sweep, all 304 |
| planning | 6h | 1 pt per repo | 1 pt per repo | labels and milestones |
| joblogs | off | 1 core per repo, 1 blob per failed job | 0 core | the failed runs of the last hour per repository, asked of a list filtered to the month a re-run can reach back to, and one blob per failed job from object storage, outside the API’s quota; the filter’s URL changes once a day, so a day costs one charged page per repository and the rest are 304 |
| settings | 6h | 2 core per repo, 1 webhook_deliveries per hook | 0 core, 1 webhook_deliveries per hook that moved | webhooks, environments and deploy keys; the deliveries of every hook are charged to their own bucket |
| rulesets | 24h | 1 core per repo plus 1 per ruleset | 0 core | one list per repository and one history per ruleset, both with an ETag; none of it charged on a day nobody edited a ruleset, when the steady sweep asks the same questions and every one of them is conditional |
| inventory | 24h | 4 core per repo | 0 core | the workflow token policy, the secrets, the code scanning setup; the refusals were the 403 of repositories without it, now remembered for a day, which at this cadence is the next sweep anyway; the rest are conditional requests, all 304 |
| deployments | 1h | 1 pt per 5 repos | 1 pt per 5 repos | one query per five repositories, the newest hundred deployments each |
| policyfiles | 24h | 1 pt per 5 repos | 1 pt per 5 repos | one query per five repositories, the history of four paths each |
| deps | off | 1 core and 1 dependency_sbom per repo | 0 core, 1 dependency_sbom per repo that received a commit | one commit read and one SBOM per repository; the SBOM has its own bucket and most were 404, now remembered for a day. GitHub regenerates a SBOM on every read and its ETag never matches, so the photograph is taken only when the head moved since the last sweep (the head itself is a free 304 when it did not): 0 dependency_sbom on a repository without a commit, one per repository that received one, and one of the SBOM reads timed out on GitHub’s side on each of the three sweeps measured |
What weighed before the round was not REST but GraphQL: issues and
discussions were 88 % of the points, because both queries were sized for a
backfill and asked on every sweep. The only search is the commit count of
totals, one a sweep against a budget of thirty a minute.
The round of 2026-09-11 lowered several of these rows, and the first
measurement is the baseline it is measured against: the job lists a run has
already had expanded are not asked for again, the runs page is thirty on an
ordinary sweep, discussions is asked only of repositories with a forum and
for ten threads, pulls is sized to the repository and windowed to two
cadences, notifications and the event feed stop at what the previous sweep saw,
a 403 or 404 is remembered for a day instead of being charged again every hour,
and the newest stars and forks, the starred list, the outbound searches and ten
of the eleven totals counts moved from REST to GraphQL, the same rows for a
point apiece instead of a request apiece. Measured again after the round, three
sweeps of one process on the evening of the same day, the steady sweep charges
about a third of the core and a third of the GraphQL points it charged
before, moves half the bytes on the wire, and answers about four fifths of its
requests with a 304. Projected to a day at the built-in cadences that is
roughly an eighth of the REST calls and a third of the points, plus one job
list per workflow run completed, which is the one term that grows with how busy
the repositories are rather than with how many there are. Two things did not
fall. The cold sweep charges more core than before, because the first sweep
of issue events now reads the whole month through the timeline and, where pull
requests are stacked, one per-issue list for nearly every pull request updated
in it: minutes, once per process. And a sweep that runs every family at once
still takes minutes rather than seconds, because its conditional requests cost
a third of a second each and its GraphQL queries nine tenths, one after the
other; the sweeps production runs are the 15 minute one and the hourly one,
each a fraction of the whole.
GraphQL is the cheap one, by a wide margin
Section titled “GraphQL is the cheap one, by a wide margin”One query returns the full 366-day contribution calendar, every contribution total, the per-repository commit breakdown and the social counts, for one point of a five thousand point budget. The same data over REST would be dozens of calls and would not include the calendar at all, because the calendar exists nowhere else.
That is why the account family runs on a twelve-hour cadence and still costs almost nothing, and why the expensive families are the REST ones that scale with how busy the repositories are.
Two families scale with activity, not with size
Section titled “Two families scale with activity, not with size”Everything else costs a roughly fixed number of calls per repository. Two do not:
actionscosts a page of thirty runs, up to seven while they come full of runs newer than the window, plus one request per run whose jobs this process has not written yet, at most twenty a sweep. A repository with continuous integration on every push generates runs continuously; a quiet one costs the one page, answered 304, and nothing else.artifactswalks up to five pages per repository, and a busy repository fills them.
Switching a family off
Section titled “Switching a family off”Set its interval to 0.
every: families: artifacts: 0 joblogs: 0A family that is off writes nothing and costs nothing. Its dashboard panels go empty, which is the honest reading. See cadences.
Reading the arithmetic for your own account
Section titled “Reading the arithmetic for your own account”A first sweep is the expensive one: the full stargazer walk, a month of
workflow runs, and (with every.history set) every past year’s contribution
calendar. After that, multiply the per-repository rows above by the number of
repositories -list prints, and divide the hourly budget by the cadence.
A card is priced as a cold sweep whatever the cadences say: the run collects every family, because every number it draws comes from that one sweep, and its process starts with an empty ETag cache. So N cards are N sweeps, and a workflow drawing three of them pays three.
The signal that the sum came out wrong is a warning, not a guess:
level=WARN msg="rate limit reserve reached, family skipped" family=actionsOnce in a while is fine. Every sweep means the cadences are too fast for the number of repositories.