Skip to content

Our runners against GitHub-hosted, measured.

The same open-source projects, built cold with their own CI commands on a 2 vCPU runner from each of us, 4 times a month. GitHub’s own records time both, and a person reads every round before it appears here.

1.3–2.2×faster
End to end, than GitHub-hosted

Per workload, a whole job — queue, machine start, dependencies and build — was between 1.3× and 2.2× faster than on GitHub-hosted, each workload compared only over the rounds both runners completed.

2.8×cheaper
Per build, than GitHub-hosted

The same builds cost $0.012 here against $0.034 there, at $0.004 a minute against $0.006. Both are billed time only, each by its own provider's rounding rule; the queue is in neither.

The result, workload by workload

One row each, every figure the whole job end to end and every percentage against our row. The queue column is the one we do not win; the method below says why, and it stays on the table because your pull request waits through it either way.

ProviderMachineeslintcargoprometheusactionlintQueueSwingOverallPer minuteCost per build
runners.io

everything below is measured against this row

2 vCPU / 8 GB157s
140–482s
255s
246–323s
175s
157–559s
not run36s±256%baseline$0.004$0.012
GitHub-hosted2 vCPU / 8 GB197s+25%
168–227s
561s+120%
354–628s
270s+54%
196–352s
not run27s±80%1.67× slower$0.006$0.034+183%

The workloads

Three public projects, each checked out at a pinned commit, its toolchain installed the way the project’s own CI installs it, its dependencies fetched fresh, and then its own CI command. Nothing here was written by us to be fast on our runners.

WorkloadProject, at the pinned commitToolchainDependenciesBuild
eslinteslint/eslint
c27bc926e496
actions/setup-node, node-version lts/*npm install --no-audit --no-fundnode Makefile mocha
cargorust-lang/cargo
e8cb624d5701
dtolnay/rust-toolchain@stablecargo fetch --lockedcargo build --release --locked
prometheusprometheus/prometheus
d15adb9ad7e5
actions/setup-go, go-version-file: go.mod, cache: falsego mod downloadgo test ./tsdb/ -test.tsdb-isolation=false

How it is measured

Reading the table

Every percentage is against our own row, and on all of these columns less is better — so a positive number is slower or dearer than us, and a negative one beats us. Read the two rows here as two measurements built the same way, not as a map of the market. Shorter bar is better: each is drawn against the slowest row in its own column, which is why a bar cannot be compared across columns — a cargo build takes minutes and an eslint run takes seconds. Colour grades the gap on a fixed scale: yellow is level with us, orange half behind, red twice the time or the price. Our own row is the mark everything is set against and is shown green; on any other row green would mean that provider beat us, and nothing earns it for merely keeping up.

The machine on each row

Machine is the shape each provider advertises, at the label the benchmark asked for. Both rows are the same shape. GitHub’s is the private-repository runner; a public repo gets 4 vCPU / 16 GB and that row would move.

What arrives is not always what is advertised. Probed inside every timed job by saturating every visible core, we deliver 1.9 of our 2 advertised cores and GitHub delivers 1.8 of its 2. Both are medians over the 44 approved rounds from 2026-09-07 to 2026-10-08 in which our guest was shown the 2 vCPU we sell. Rounds before 2026-09-07 18:45 UTC ran our guest with 4 vCPU rather than the 2 we sell, and were withdrawn from this comparison because that machine is no longer sold.

What is timed, and who times it

Times are end-to-end wall clock — from the workflow being created to the job finishing, including queue, provisioning, checkout, toolchain, dependencies and build. They are read from GitHub’s own API rather than reported by the runners, so no provider times itself, ourselves included. GitHub-hosted is not the reference row, but it is measured exactly the same way: if that is what you run today, read that row. The cost column is not end to end; it is worked out from billed time only, below.

Queue

Queue is how long a job waits before a machine picks it up — created to started. It is already inside the workload times to its left, not added to them, so do not count it twice. Nobody charges for it, ourselves included: our meter starts when your code starts, so queue never reaches the cost column. It is here because your pull request is blocked for it either way, and a runner that is quick once it starts but slow to find you a machine is not a quick runner. The figure is a typical wait rather than a worst case: a job that queued three or more times its own provider’s median queue is dropped, and the longest wait it removed on this data was 95 seconds.

This is the column we lose. A job typically waited 36s for one of our machines, against 27s on GitHub-hosted. GitHub’s scheduler hands a job straight to its own runners. Ours has to hear about the job from GitHub, give it a fresh machine and register that machine as a runner before the job can be assigned to it — time the arrangement costs before any of your code runs, rather than a slow machine. It is already inside every end-to-end figure above, so the speed comparison pays for it in full.

How cost per build is worked out

Cost per build is worked out only from the time each provider bills: the job itself, from the moment a machine picks it up to the moment it finishes, as GitHub records it. Queue is in no row’s cost, ours included. Each provider’s minutes follow that provider’s own billing rule: we round each job up to the whole minute, with a one-minute minimum; GitHub rounds each job up to the whole minute. Each workload’s median job is billed under that rule at the provider’s published rate for the exact machine it was benchmarked on, and the workloads are averaged. Both rows use the pay-as-you-go rate — what a minute costs someone who signed up and ran a build, with no commitment and no prepaid discount.

Cold builds, and each row's configuration

Every provider’s configuration, in full. The benchmark is built to measure a cold build on every row: it never calls actions/cache, dependencies are fetched fresh each run, and toolchains are installed by each project’s own workflow. Anything that would carry state from one run into the next is turned off, so that a row measures a provider rather than that provider’s history. That is also the reason some of their features do nothing here, which we would rather say than let a cold run be mistaken for a full product comparison.

ProviderConfiguration
runners.io
  • Its own virtual machine per job, destroyed when the job ends — never reused between jobs.
  • The guest is shown the 2 vCPU we bill. Until 2026-09-07 it was shown 4 and capped above what we bill.
  • Our own Actions cache and Docker layer cache are deployed and running, and are idle here for the same reason theirs are: this test never calls actions/cache.
  • Our node-local package proxy is OFF in every published round — it is an experiment, not a shipped feature, and no customer gets it.
GitHub-hosted
  • Standard runner on a private repository: 2 vCPU / 8 GB.
  • A public repository would get 4 vCPU / 16 GB and a faster row.

Rounds, the window, and the one exclusion

Each figure is the median of the rounds that provider completed, taken from 44 rounds between 2026-09-07 and 2026-10-08 — the window reaches about 55 days back from the newest round, so older rounds stop counting — with the observed range beneath it and the worst swing in its own column. Providers miss rounds: a runner is never scheduled, a build falls over. So a comparison uses only the rounds both providers ran, recomputed rather than read off the two medians above — otherwise a provider that happened to miss the busy hours would be judged on its good ones. Rounds are never discarded for being incomplete; the round nobody finished is the one worth keeping. Not run means no provider completed that workload in the rounds a row rests on: the benchmark did not run it then, so it counts against nobody. The thinnest comparison here rests on 43 shared rounds. Rounds are taken 4 times a month, each at a different hour (04:17, 09:17, 14:17 and 20:17 UTC), so working hours are in the sample as well as the quiet hour: a benchmark run only at 04:00 measures the one time nobody’s CI is busy. Rounds before October 2026 were taken four times a day. Rounds that flatter us least still count — publishing only a favourable hour would be true and indefensible at once. One exclusion, applied to everybody. A job whose queue was at least three times that provider’s own median queue is dropped — that is GitHub’s scheduler, not the machine we are measuring. It is keyed to each provider’s own behaviour rather than to a round, so it cannot remove our bad day and keep a competitor’s. In this sample it removed 1 job: GitHub-hosted (1).

A person reads every round first

A round lands in our database unapproved, and nothing on this page, on the pricing page or anywhere else on the site reads it until somebody has looked at it and approved it. Approval means the round was read and is not obviously broken — a workload that half-ran, a runner wedged on a package mirror. It never means the round flatters us: the rounds where we look worst are approved on the same terms, and one approved by mistake can be revoked, at which point every figure here is recomputed without it.

Rounds run 4 times a month. If the newest approved round is more than 12 days old, a round either did not arrive or nobody has read it yet, and the top of this page says how old the comparison is rather than letting it pass for today’s. The speed and cost multiples are not stated at all until both runners share at least 3 approved rounds on every workload.

The rounds behind these figures

Every approved round in the current window, newest first. The repository the benchmark runs in is private — a public one would be given a larger GitHub runner than the one compared here — so its run pages cannot be opened from this site. The recipe below is how to check us instead.

RoundStartedCompleted by
378100541472026-10-08 16:36 UTCrunners.io and GitHub-hosted
372292245192026-10-04 19:41 UTCrunners.io and GitHub-hosted
357098307362026-09-22 09:21 UTCrunners.io and GitHub-hosted
354730165672026-09-19 22:20 UTCrunners.io and GitHub-hosted
354576711392026-09-19 17:18 UTCrunners.io and GitHub-hosted
354450340162026-09-19 13:12 UTCrunners.io and GitHub-hosted
354330831252026-09-19 08:50 UTCrunners.io and GitHub-hosted
353759095012026-09-18 17:42 UTCrunners.io and GitHub-hosted
352579601502026-09-17 18:18 UTCrunners.io and GitHub-hosted
352327542402026-09-17 14:19 UTCrunners.io and GitHub-hosted
350788487552026-09-16 09:20 UTCrunners.io and GitHub-hosted
350332209592026-09-15 22:54 UTCrunners.io and GitHub-hosted
350062736932026-09-15 18:15 UTCrunners.io and GitHub-hosted
349809877242026-09-15 14:21 UTCrunners.io and GitHub-hosted
349526026592026-09-15 09:28 UTCrunners.io and GitHub-hosted
349075202982026-09-14 23:09 UTCrunners.io and GitHub-hosted
348867998902026-09-14 19:25 UTCrunners.io and GitHub-hosted
348305907062026-09-14 09:56 UTCrunners.io and GitHub-hosted
347867568272026-09-13 22:26 UTCrunners.io and GitHub-hosted
347715654542026-09-13 17:26 UTCrunners.io and GitHub-hosted
347608907372026-09-13 13:49 UTCrunners.io and GitHub-hosted
347497744842026-09-13 09:35 UTCrunners.io and GitHub-hosted
347224431082026-09-12 22:20 UTCrunners.io and GitHub-hosted
347076076652026-09-12 17:14 UTCrunners.io and GitHub-hosted
346948973182026-09-12 12:53 UTCrunners.io and GitHub-hosted
346838692802026-09-12 08:40 UTCrunners.io and GitHub-hosted
346547506572026-09-11 22:37 UTCrunners.io and GitHub-hosted
346295217312026-09-11 17:46 UTCrunners.io and GitHub-hosted
346053637162026-09-11 13:37 UTCrunners.io and GitHub-hosted
345817126592026-09-11 08:57 UTCrunners.io and GitHub-hosted
345382817482026-09-10 22:36 UTCrunners.io and GitHub-hosted
345099193232026-09-10 17:43 UTCrunners.io and GitHub-hosted
344839524652026-09-10 13:39 UTCrunners.io and GitHub-hosted
344581226432026-09-10 08:59 UTCrunners.io and GitHub-hosted
344128755812026-09-09 22:34 UTCrunners.io and GitHub-hosted
343854104092026-09-09 17:51 UTCrunners.io and GitHub-hosted
343591526322026-09-09 13:46 UTCrunners.io and GitHub-hosted
343321930102026-09-09 09:00 UTCrunners.io and GitHub-hosted
342872144042026-09-08 22:42 UTCrunners.io and GitHub-hosted
342600892552026-09-08 17:57 UTCrunners.io and GitHub-hosted
342333649772026-09-08 13:40 UTCrunners.io and GitHub-hosted
342070683072026-09-08 08:54 UTCrunners.io and GitHub-hosted
341680764182026-09-07 22:51 UTCrunners.io and GitHub-hosted
341528893772026-09-07 18:45 UTCrunners.io and GitHub-hosted

Re-run it yourself

  1. Use a private repository. GitHub gives a public one a 4 vCPU / 16 GB runner, not the 2 vCPU / 8 GB one compared here.
  2. Put both runners in one workflow run, one job each per workload, so every job starts from the same instant: runs-on: ubuntu-latest and runs-on: runnersio-2vcpu-ubuntu-2404. (The benchmark asks for the older spelling of the same machine, ghwarp-2vcpu-ubuntu-2404.)
  3. In each job, check out the project at the commit in the table above, install the toolchain as listed, fetch the dependencies and run the build command. Do not call actions/cache or carry anything from one run to the next.
  4. Read the times from GitHub’s REST API rather than the job log: the run’s created_at and each job’s started_at and completed_at. End to end is created to completed, queue is created to started, and billed time is started to completed.
  5. Run it more than once, at different hours, and compare medians over the runs both runners completed. One run is one sample.
.github/workflows/benchmark.yml
on: workflow_dispatch permissions: {} jobs: cargo: strategy: matrix: runner: [ubuntu-latest, runnersio-2vcpu-ubuntu-2404] runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v5 with: repository: rust-lang/cargo ref: e8cb624d5701824f46a2ec5873cfd59ee3d2f66c fetch-depth: 1 - uses: dtolnay/rust-toolchain@stable - run: cargo fetch --locked - run: cargo build --release --locked