Removing Cold Starts from GitHub Actions Jobs

A GitHub Actions cold start is the setup time before your first real step runs. Split it into five parts, then cut it with standby disks and snapshots.

Last verified:

A GitHub Actions cold start is the stretch between a job entering the queue and the first line of your own work running, and it is five separate costs stacked on top of each other: runner allocation, image boot, toolchain install, dependency restore, and first-run compilation. Two documented levers cut different parts of that stack: standby disks let a BYOC runner start a job in about 15 seconds, and snapshot runners boot from saved VM state in 45 to 60 seconds with the toolchain and dependencies already on disk.

This guide splits the stack so you can measure each part from the job log, matches each lever to the parts it actually removes, gives the configuration for both, and converts per-run setup seconds into weekly wait at a stated job volume.

Diagnosis

Treating cold start as one number hides which part you are paying for, and the five parts respond to completely different fixes. Get a duration for each before changing anything.

Runner allocation. The gap between the job appearing in the queue and a machine being assigned to it. On a fixed self-hosted pool this is wait for a free runner. On a per-job provisioner it is the time to select an instance type, launch a VM, and register the runner agent with GitHub. Nothing of yours runs during this window and nothing is billed as runner minutes, so it disappears from invoices and shows up only as people waiting. If this part dominates, read the guide to GitHub Actions queue times first, since allocation and queueing share most of their causes.

Image boot. The machine exists and now has to become usable: kernel boot, cloud-init or equivalent, disk attach, network configuration, container runtime start, and the runner agent handshake. In the log this is folded into the Set up job step along with allocation, which is why the two are usually measured together.

Toolchain install. Every setup-* action, apt-get install, brew install, SDK download, and container image pull that happens before your build command. A job that installs a language runtime, a package manager, a linter, and two CLIs pays for four network round trips and four extractions on every single run. These steps each carry their own duration in the GitHub Actions log, so this part is the easiest of the five to quantify.

Dependency restore. npm ci, bundle install, go mod download, pip install, or a cache restore action pulling a tarball and unpacking it. Cache restore is faster than a fresh install and still costs a download plus an extraction proportional to the size of the tree. A 1.5 GB node_modules restores slowly whatever the cache hit rate.

First-run compilation. The compiler starts with empty incremental state, so it rebuilds output that the previous run already produced. Rust rebuilds the dependency graph into target/, Gradle rebuilds without a warm daemon or build cache, TypeScript rebuilds without .tsbuildinfo, and a Docker build without a warm layer cache re-executes every layer. This part is invisible in the step list because it hides inside your build command, so it shows up as the build step taking longer on a fresh machine than it does locally.

Getting numbers per part

Three passes give you the split without new tooling.

  1. Read Set up job in the raw log. Expand it and take the timestamp difference between the job start and the first line of the first real step. That single number covers allocation and image boot together.
  2. Read the step durations printed next to each step name. Toolchain install and dependency restore are already broken out if each setup-* action and each install command is its own step. If they are bundled into one shell script, split them for a week.
  3. Isolate first-run compilation by running the same commit twice against a warm cache and taking the difference in the build step. What remains after the warm run is your steady-state build time, and the gap is the cold penalty.

CI observability adds the system-level view on top of the durations. The runner agent reports OpenTelemetry system metrics correlated with the job logs, so a dependency restore that is bound by disk throughput looks different from one bound by network.

Fix

The two levers cut different parts of the stack, and neither one covers all five. Match the lever to the part that dominates your split.

Standby disks cut allocation and boot on BYOC

A standby disk is a VM of the same instance type as the runner, booted and then immediately shut down. The boot has already initialized the VM, set up networking, and applied the rest of the machine configuration, so when GitHub requests a runner the control plane powers a waiting disk back on rather than building a machine from scratch. That takes the runner from request to a job starting in about 15 seconds, per the standby disks documentation.

Four facts decide whether this lands:

  • The pool is maintained by the WarpBuild control plane, and reconciliation runs within about a minute. A burst that drains the pool is refilled on that timescale, so size the pool for concurrent jobs of that runner type rather than for daily volume.
  • Spot instances are not supported for standby disks. A spot instance can be reclaimed at any time, so a standby instance cannot be guaranteed.
  • If no standby disk is available, a new VM is created and allocated to the job. The job still runs; it starts at the slower time.
  • The instance type of a standby disk matches the runner type. Where fallback instance types are configured, the standby disk is one of those types.

Standby disks are a BYOC feature. BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS.

Snapshot runners cut toolchain, dependencies, and first-run compilation

A snapshot runner boots from a saved VM state captured mid-workflow instead of rebuilding that state on every run. Boot takes 45 to 60 seconds, which is slower than a default runner boot, and the trade is deliberate: whatever the snapshot captured is already on disk when the job starts. If the snapshot was taken after npm ci and a full build, then toolchain install, dependency restore, and first-run compilation are all done before your first step executes.

The scope rules matter more than the feature does:

  • Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners.
  • BYOC, Windows, and macOS runners are unsupported, and a snapshot label on those runner types is silently ignored while the job runs normally. Nothing fails, so the missing speedup is easy to miss.
  • Snapshots are temporary and are deleted after 15 days.
  • The /tmp directory does not persist state, because it is cleaned on reboot.

Full behavior is in the snapshot runners documentation.

Which lever cuts which part

Cold start partStandby disks (BYOC)Snapshot runners (Cloud Ubuntu)
Runner allocationCut to about 15 seconds with image bootUnchanged
Image bootCut to about 15 seconds with allocation45 to 60 seconds from saved state
Toolchain installUnchangedRemoved when captured in the snapshot
Dependency restoreUnchangedRemoved when captured in the snapshot
First-run compilationUnchangedRemoved when captured in the snapshot

Provisions a machine per job on all of them, so the allocation part has no shared pool to saturate on any platform. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That matters for standby disk sizing: the pool changes how fast a job starts, never how many jobs may run at once.

Configuration

Standby disk count per custom runner on BYOC

The number of standby disks is configurable per custom runner. Set it in the custom runners view of the dashboard for the runner type you want to start quickly, and choose the count from the number of jobs you expect to run concurrently on that runner type. A repository whose pull request workflow fans out to eight shards on one runner type wants at least eight, or it gets fast starts for the first few shards and normal starts for the rest.

Reference the runner in the workflow by its Runner ID, which is the runner name prefixed with warp-custom-:

name: test

on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: warp-custom-ubuntu-x64-8x
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4, 5, 6, 7, 8]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx jest --shard=${{ matrix.shard }}/8

The runs-on value is the only WarpBuild-specific line. Standby disks change the start time of that job without any workflow change at all, which makes the before and after comparison clean: hold the workflow constant, change the pool count, and read the Set up job duration across a week.

The snapshot.key label on Cloud Ubuntu runners

Snapshots attach to the runs-on label with a semicolon. Two forms exist:

  • snapshot.enabled=true turns the feature on and always boots from the base image. Pair it with the snapshot-save action to capture state at the point you choose.
  • snapshot.key=<alias> turns the feature on and boots from an existing snapshot for that alias when one exists. With no snapshot yet, the runner boots from the base image.

The pattern that fits most repositories is to build the snapshot on main and consume it on pull requests:

name: build

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  build:
    runs-on: >-
      ${{ github.ref == 'refs/heads/main'
        && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
        || 'warp-ubuntu-latest-x64-8x;snapshot.key=web-app-warm' }}
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - run: npm ci
      - run: npm run build
      - run: npm test

      - name: Cleanup credentials
        if: github.ref == 'refs/heads/main'
        run: |
          rm -rf $HOME/.ssh $HOME/.aws
          git clean -ffdx

      - name: Save snapshot
        uses: WarpBuilds/snapshot-save@v1
        if: github.ref == 'refs/heads/main'
        with:
          alias: "web-app-warm"
          fail-on-error: true
          wait-timeout-minutes: 60

Three configuration notes carry real weight here.

Clean up before you save. The snapshot keeps whatever is on disk, including credentials written by earlier steps. Run rm -rf $HOME/.ssh $HOME/.aws and git clean -ffdx before the save step. On public repositories this is mandatory, since anyone can boot the snapshot by referencing the alias in a pull request workflow. On private repositories, runners are provisioned at the organization level and GitHub may hand a runner to a different job in the same organization, so the same cleanup applies.

Keep the alias stable and the content fresh. A job booted from a snapshot exposes WARPBUILD_SNAPSHOT_KEY set to the alias, which is a cheap assertion to add to a step if you want the log to record whether the boot was warm. Snapshots are deleted after 15 days, so an alias that is only refreshed on release days eventually goes cold and every consumer quietly falls back to a base image boot.

Do not point snapshot labels at unsupported runner types. A snapshot.key on a warp-custom- BYOC label or a Windows or macOS label is ignored without an error.

Cost or Time Model

Per-run setup seconds

The profile below is an example shape for a Node service with a moderate dependency tree. Substitute your own numbers from the three measurement passes in the diagnosis section. Allocation and image boot are merged into one row because the documented 15 second standby figure covers both together.

Cold start partBaseline secondsBYOC with standby disksCloud Ubuntu with snapshot.key
Runner allocation and image boot651560
Toolchain install35350
Dependency restore606010
First-run compilation454515
Total setup per run20515585

The snapshot column keeps 10 seconds of dependency restore and 15 seconds of compilation, because a snapshot taken yesterday still has to pick up whatever moved since. Modelling either as zero overstates the result.

Weekly wait at a stated job volume

Assumptions, stated so you can replace them:

  • 1,200 GitHub Actions jobs per week, which is 240 per working day across 5 days.
  • Every job pays the setup profile above.
  • 1 job in 4 gates a person who is actively waiting, such as a pull request check before review.

The arithmetic:

  • Baseline: 205 seconds x 1,200 = 246,000 seconds, or 4,100 minutes, or 68.3 hours of setup per week.
  • Standby disks: 155 x 1,200 = 186,000 seconds, or 3,100 minutes, or 51.7 hours.
  • Snapshot runners: 85 x 1,200 = 102,000 seconds, or 1,700 minutes, or 28.3 hours.

That is 1,000 minutes per week returned by standby disks and 2,400 minutes per week returned by snapshots, or 16.7 and 40.0 hours. At the 1-in-4 watched ratio, the snapshot configuration hands about 10.0 engineer hours per week back to people who were sitting on a check.

What each lever costs

Standby disks. WarpBuild does not charge for standby disks. The cost lands on your own cloud bill in two pieces: the VM is billable for about 90 seconds while the standby disk initializes, after which it is shut down, and the network disk stays billable while the VM is shut down. Multiply your cloud provider's disk price by the pool size for the standing line item, and count 90 seconds of instance time each time a disk in the pool is created.

Snapshot runners. Snapshot restore is $0.04 per job and snapshot storage is $0.025 per snapshot-hour, from the pricing page, checked on 2026-08-13. The restore fee is worth covering against runner minutes removed, and the breakeven depends entirely on the size of the runner:

Runner labelRate per minuteJob minutes that cover the $0.04 restore
warp-ubuntu-latest-x64-2x$0.00410.0
warp-ubuntu-latest-x64-4x$0.0085.0
warp-ubuntu-latest-x64-8x$0.0162.5
warp-ubuntu-latest-x64-16x$0.0321.25
warp-ubuntu-latest-x64-32x$0.0640.625

Apply that to the profile above. The billed part of a job starts once the machine exists, so the snapshot removes 35 + 50 + 30 = 115 seconds of billed time per run, which is 1.92 minutes. On warp-ubuntu-latest-x64-16x at $0.032 per minute that is $0.061 of runner time against a $0.04 restore, so the fee is covered. On warp-ubuntu-latest-x64-8x at $0.016 it is $0.031 against $0.04, so the invoice moves slightly the other way and the wall clock is the reason to keep it.

Storage is the smaller line. One alias held continuously for a week is 168 hours x $0.025 = $4.20, and a snapshot that is never refreshed reaches the 15-day deletion point at 360 hours x $0.025 = $9.00 across its whole life.

The rest of the pricing picture

Two follow-ons. The catalog, labels, and behavior for the snapshot feature are on WarpBuild snapshot runners for GitHub Actions, and the pool sizing detail for BYOC is on standby disks for BYOC runners. Once the setup profile is flat and the build command itself is the remaining cost, continue with incremental builds on GitHub Actions.

FAQ

What counts as a cold start on a GitHub Actions job?

Everything between the job entering the queue and the first line of your own work, covering runner allocation, image boot, toolchain install, dependency restore, and first-run compilation. The Set up job step in the log covers allocation and boot, and the steps that follow carry the other three. Measure the five parts separately, because each one has a different fix.

Do standby disks work with spot instances?

No. Spot instances are not supported for standby disks, because a spot instance can be reclaimed at any time and a standby instance cannot then be guaranteed. Run standby disks on on-demand instance types and keep spot for the runner types where a slower start is acceptable.

Why do snapshot runners boot in 45 to 60 seconds when standby disks start a job in about 15 seconds?

They cut different parts of the stack. A snapshot runner boots from saved VM state, which takes 45 to 60 seconds and is slower than a default runner boot, and the payoff is that the toolchain, dependencies, and prior build output are already on disk when the job starts. Standby disks cut allocation and boot on BYOC and leave the three later parts untouched.

Can I use snapshot runners on BYOC, Windows, or macOS runners?

No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. A snapshot.enabled or snapshot.key label on a BYOC, Windows, or macOS runner is silently ignored and the job runs normally, which makes the failure hard to spot. On those runner types, use standby disks on BYOC and step-level caching everywhere else.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.