Caching often halves CI time, and artifacts are the only way to pass build output between jobs.
Caches and artifacts both store files, for different purposes.
A cache is an optimization: dependencies that can be recreated if missing. actions/cache saves a path under a key at the end of a job and restores it at the start of later runs. Build the key from a hash of the lockfile, ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}, so it changes when dependencies change. restore-keys lists prefixes to fall back to a partial match. Easier still, most setup-* actions have a built-in cache: input. Caches are scoped to a branch with fallback to the default branch, evicted after 7 days unused, and limited in total size per repository.
An artifact is an output: build results, test reports, coverage. actions/upload-artifact stores files with the run; actions/download-artifact fetches them in a later job of the same run, and people can download them from the run page. Set retention-days to control storage costs. Since v4, artifact names must be unique within a run, so matrix jobs should include the matrix values in the name.
Rule of thumb: if losing it only makes the build slower, it's a cache; if a later job or a human needs it, it's an artifact.
Example
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: {node-version: 22, cache: npm} # caches ~/.npm by lockfile hash
- run: npm ci && npm run build
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
retention-days: 5
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with: {name: dist, path: dist}
- run: ls dist # index.html assets/ ...Remember
- Cache = recreatable dependencies keyed by a lockfile hash
- Artifact = outputs passed to later jobs or people
- setup-* actions have a built-in cache input
Watch out for
- Caching node_modules instead of the package manager's download cache, which breaks across Node versions.