Cold Start
A cold start is the time between a GitHub Actions job being queued and its first useful step, spent on runner allocation, image boot, and setup.
A cold start is the time between a job being queued and that job doing useful work, spent on allocating a machine, booting the machine and its image, and setting up the environment the job needs. The steps you actually care about have not run yet during that window, so it is overhead added to every run.
The term is borrowed from serverless computing, where a cold start is the first invocation on a freshly created instance. GitHub Actions has the same shape one level up, at the job: a job that lands on a machine with no history pays for everything that machine does not already have.
Definition
A cold start is measured from the moment a job is queued to the moment the first step you wrote begins doing its work. Everything inside that window is preparation for the work rather than the work.
Four costs sit inside it, and they always occur in this order.
Runner allocation. The gap between the job entering the queue and a machine being assigned to it. On a fixed pool of long-lived machines this is a wait for a free one. On a per-job provisioner it is the time to select an instance type, launch a virtual machine, and let the runner agent register with GitHub. Nothing of yours runs here, and on most billing models nothing is metered here either, which is why this part disappears from invoices and shows up only as people waiting.
Image boot. The machine exists and now has to become usable: kernel boot, cloud-init or its equivalent, disk attach, network configuration, container runtime start, and the agent handshake that makes the runner eligible for work.
Toolchain install. Every setup-* action, apt-get install, brew install, SDK download, and container image pull that runs before your build command. A job that installs a language runtime, a package manager, a linter, and two command line tools pays for four downloads and four extractions on every run.
Dependency restore. npm ci, bundle install, go mod download, pip install, or a cache action pulling a tarball and unpacking it. A cache hit is faster than a fresh resolve and still costs a download plus an extraction proportional to the size of the tree.
Where the window ends
The start of the window is unambiguous. The end is fuzzy, because the first step you wrote often carries setup cost of its own.
A compiler on a fresh machine begins with empty incremental state and rebuilds output that the previous run already produced. Rust repopulates target/, Gradle runs without a warm daemon, TypeScript runs without .tsbuildinfo, and a container build without a warm layer cache re-executes every layer. That first-run penalty hides inside your build command rather than appearing as its own step, and it belongs to the cold start by any honest accounting even though no log line marks it.
Pick one boundary and hold it. The practical choice is the first line of the first step that produces something the job exists to produce, with the first-run compilation penalty tracked as a separate number underneath it.
Two numbers that get folded in by mistake
Concurrency wait is the first. A job held back by a workflow concurrency group, an organization limit, or a runner group restriction is waiting for permission rather than for hardware. It looks identical from the run view, since both cases show a job with no logs, and the fixes have nothing in common.
Steady-state build time is the second. That is what remains when the machine, the toolchain, and the dependency tree are already in place. Adding it to the cold start makes every improvement look smaller than it was and hides which half of the run actually moved.
Warm and cold describe the machine
Cold and warm are properties of the machine that received the job.
A long-lived runner keeps whatever the previous job left on disk. The second job to land there finds a populated package cache, pulled container images, and a checked out repository, so its window is short. It also finds half written files from a job that was cancelled, which is the trade.
An ephemeral runner takes one job and is destroyed afterwards, so every job starts on a machine with no history and the full window is present by default. What shortens it is state that arrives with the machine rather than state left by an earlier job.
The levers, by phase
Each part of the window responds to a different mechanism, and none of them touches all four.
| Phase | What shortens it |
|---|---|
| Runner allocation | Machines booted in advance and held ready, so the request is answered by a machine that already exists |
| Image boot | A smaller image, fewer services started at boot, or resuming a machine from saved state instead of booting from scratch |
| Toolchain install | Baking the toolchain into the machine image, so the job finds it installed at step one |
| Dependency restore | A cache keyed on the lockfile, a disk that already holds the tree, or a smaller tree |
| First-run compilation | Restored incremental state: a compiler cache, a build cache, or a warm container layer cache |
Reading that table top to bottom explains why a single change rarely moves the total much. Baking a toolchain into an image removes the third row and leaves the first two exactly where they were.
Example
The first minute of a Node job
The profile below is an example shape for a service with a moderate dependency tree, running unit tests on a per-job machine. Substitute your own measurements; the point is the split rather than the figures.
| Elapsed | Phase | Duration | What is happening |
|---|---|---|---|
| 0:00 | Job queued | GitHub creates the job and looks for an eligible runner | |
| 0:00 to 0:22 | Runner allocation | 22 s | An instance is selected and launched, and the agent registers |
| 0:22 to 0:38 | Image boot | 16 s | Kernel, network, disk, container runtime, agent handshake |
| 0:38 to 0:57 | Toolchain install | 19 s | actions/setup-node downloads and extracts the runtime |
| 0:57 to 1:14 | Dependency restore | 17 s | A 480 MB node_modules cache is downloaded and unpacked |
| 1:14 | First useful step | npm test starts |
Seventy four seconds pass before the test command runs. If the suite itself takes 105 seconds, the job occupies 179 seconds of wall clock and 41 percent of that is preparation. Multiply by the number of jobs in the workflow and by the number of pull requests per day to see why the figure gets attention.
Reading the same numbers from your own logs
A workflow can time the second half of the window and only the second half, because it does not exist during the first half. Timestamps written between steps give you the toolchain and dependency parts:
name: cold-start-profile
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Mark machine ready
run: date +%s > /tmp/t0
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Mark toolchain ready
run: date +%s > /tmp/t1
- run: npm ci
- name: Mark dependencies ready
run: date +%s > /tmp/t2
- name: Print the split
run: |
t0=$(cat /tmp/t0)
t1=$(cat /tmp/t1)
t2=$(cat /tmp/t2)
echo "toolchain install: $((t1 - t0))s"
echo "dependency restore: $((t2 - t1))s"
- run: npm testThe first half comes from two other places. The Set up job step in the raw log carries the timestamps for allocation and image boot together. For allocation on its own, the workflow jobs endpoint of the GitHub REST API returns created_at, started_at, and completed_at for every job, and the difference between the first two is the time the job spent waiting for a machine (GitHub REST API reference for workflow jobs, checked on 2026-08-13).
Take that difference across a week rather than on one run. Allocation is the part with the widest spread, because it depends on what else was queued at the same time.
The same job behind a different label
The durations in the timeline belong to the machine and the image that the label in runs-on selected. Changing the label changes which fleet claims the job while every other line in the file stays where it is:
jobs:
test:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm testLabels of this shape encode an operating system, an architecture, and a machine size, and GitHub treats the whole string as one opaque routing key. The mapping for the Linux x64 labels is below, from the WarpBuild cloud runners documentation, checked on 2026-08-13.
| Label | OS | vCPU | RAM | Storage |
|---|---|---|---|---|
warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD |
warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD |
warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD |
warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD |
warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD |
Machine size moves the last two rows of the timeline, since download and extraction speed scale with the machine. It leaves the first two rows alone, because allocation and boot are decided by how the machine is supplied rather than by how large it is.
Some fleets accept options appended to the label after a semicolon, which is how a job asks for a machine booted from disk state captured on an earlier run instead of from a base image. The snapshot runners documentation covers that label form and the platforms it applies to, and the standby disks documentation covers holding machines ready before a job is queued. The two mechanisms sit at opposite ends of the window: one removes work from the setup phases, the other removes waiting from the allocation phase.
Related Terms
- Removing cold starts from GitHub Actions jobs: the same five parts with measurement passes, configuration, and a weekly time model.
- Warm cache, defined: what counts as warm, how a cache key decides, and why a hit rate is the number to watch.
- Ephemeral runner, defined: the one-job lifecycle that makes every job start cold, and the mechanisms for moving state across the boundary on purpose.
- Standby disks for BYOC runners: pre-booted machines held in a pool so the allocation phase is answered by a machine that already exists.
- Snapshot runners and saved disk state: booting a fresh machine from a disk captured during an earlier run, with the label options and retention window.
- WarpBuild snapshot runners documentation: the
snapshot.enabledandsnapshot.keylabel forms, supported platforms, and cleanup guidance. - WarpBuild pricing: per minute rates by runner type.
FAQ
What is a cold start in GitHub Actions?
The stretch between a job being queued and the first step you wrote doing useful work. It covers runner allocation, image boot, and environment setup such as toolchain installs and dependency restores. Nothing your workflow exists to produce is produced during that window, so it is overhead paid once per job.
Is a cold start the same thing as queue time?
Queue time is one part of it. A job can wait because no machine has been supplied yet, which is allocation, or because a concurrency rule is holding it back, which is policy. Both show up as a job sitting with no logs, and they have different fixes, so measure them separately.
How do I measure the cold start of a job?
Take the difference between the job's created_at and started_at timestamps from the GitHub REST API for allocation, read the Set up job step in the raw log for image boot, and read the durations of the setup and install steps for the environment part. The workflow cannot time the part that happens before the machine exists, so that half comes from the log and the API.
Do ephemeral runners always pay a cold start?
Every job on an ephemeral runner starts on a machine with no history, so the full window is there by default. What shortens it is state that arrives with the machine rather than state left behind by an earlier job: a prebuilt image, a restored cache, a disk captured on an earlier run, or a machine that was booted before the job was queued.
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.