A CI job begins with two waits. The machine boots, and then a process notices there is work for it. We had already spent a lot of effort on the first wait. When we measured the second, it was large enough to matter.

What the official runner carries

The official GitHub Actions runner is a .NET application. Each installation bundles the .NET runtime plus Node 20 and Node 24, because actions can be JavaScript or container-based and the runner has to be able to execute both. That payload is present before a single step runs.

The cost is not only disk. At idle, the official runner holds roughly 67 MB of memory doing nothing. When a job arrives, the CLR pays JIT warmup and garbage collection pauses before the work starts. In a service where a microVM can be ready in 300 ms, a runner that needs about 270 ms just to notice a job is the bottleneck.

What we changed

We rewrote our runner in Rust. It is a drop-in equivalent of the GitHub runner, and after the rewrite it is 36x smaller, with 7.5x lower idle RSS and 19x lower peak RSS. The size difference is not a compression trick. A Rust binary does not ship a managed runtime, and it does not ship two Node installations.

Node is still needed, because JavaScript actions need it. Instead of duplicating a Node installation per runner, we mount one shared read-only copy into every VM. The copy is identical, the mount is cheap, and the memory is shared rather than multiplied.

The benchmark

We ran both runners against the same server, with the same protocol and the same job, on the same machine: the official actions/runner (.NET) and preloop-runner (Rust).

Benchmark comparing the official .NET GitHub Actions runner with preloop's Rust runner across install footprint, files on disk, cold start, time to listening, idle RSS, job pickup and peak RSS
The official runner against preloop's runner: same protocol, same server, same job.
MetricOfficial runnerpreloop runnerDifference
Install footprint434 MB (+85 MB Node)11.9 MB36x less
Files on disk9,301 (+278 Node)42,325x less
Cold start97 ms29 ms3.3x less
Time to listening456 ms32 ms14x less
Idle memory (RSS)67.2 MB9.0 MB7.5x less
Job pickup268 ms100 ms2.7x less
Peak memory during a job206 MB11 MB19x less

The memory numbers matter most for a warm pool. Ten idle official runners hold about 700 MB doing nothing; ten of ours hold about 90 MB.

Small numbers compound

Three effects follow from a smaller runner.

Startup budget: the interval between "this machine exists" and "this job is executing" shrinks. If the runner is a tenth of the boot cost, more of the startup budget goes to the work.

Density: memory per idle runner is memory you cannot give to another job. A 7.5x reduction in idle RSS is capacity that becomes available to run more microVMs on the same hardware.

Peak behavior: 19x lower peak matters when jobs spike. A runner that balloons under load forces the scheduler to leave headroom on every machine, and reserved headroom is capacity nobody uses.

Why owning the runner matters

A runner is a compatibility surface. It speaks the job protocol, sets environment variables, lays out the workspace, runs each step, reports logs, and handles the small pieces that make an action behave the way its author expected. Rewriting it means reimplementing all of that, and the official runner is the reference for what correct means.

The return is control over the parts that happen between steps. Owning the runner lets us instrument job execution directly, change how steps are run, and support behavior that has been on GitHub's issue list for years instead of waiting for another service to expose it.

The cost is compatibility work that never appears in a benchmark. Labels, environment variables, workspace layout, logging commands and the timing of each report all have to match what actions expect, and the official runner is the only complete specification.

It also means the component on the critical path has no runtime we do not control. When a job starts, the sequence is our scheduler, our microVM, our runner.

What it changes for a job

The visible difference is the first step starting sooner and the machine holding less memory while it waits. The less visible difference is what the savings buy: more jobs per host, and a startup path where the runner is no longer the largest remaining constant.

Performance across a CI stack is not one number. Faster hardware helps until the software on it becomes the limit. The runner was our limit, so we replaced it.