Why caching DerivedData with actions/cache doesn’t speed up Xcode builds

Caching DerivedData between GitHub Actions runs looks like the obvious fix for slow iOS CI. Usually the cache restores, the build starts, and Xcode compiles everything anyway. Here is why, measured on a real build, and what to cache instead.

TL;DR

  • Xcode’s incremental build compares file modification times. A fresh checkout gives every file a new one, so a restored DerivedData rebuilds every compile task: 28 of 28 Swift compiles re-ran in our test.
  • DerivedData is tied to the project’s path and full of absolute paths; move the checkout or the DerivedData folder and it starts over.
  • It is large and mostly not your code: 196 MB for an 8-file app, 132 MB of it precompiled SDK modules. Packing it took 11 s.
  • actions/cache entries are immutable, branch-scoped, capped at 10 GB per repository by default and evicted after 7 idle days.
  • Cache what is keyed by content instead: Swift package checkouts, and compile results through Xcode 26 compilation caching.

The usual workflow

.github/workflows/build.yml
- uses: actions/cache@v6  with:    path: ~/Library/Developer/Xcode/DerivedData    key: deriveddata-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }}    restore-keys: deriveddata-${{ runner.os }}-- run: xcodebuild build -scheme App -destination 'generic/platform=iOS Simulator'

The step reports a cache hit, the folder is there, and the build takes about as long as before. Nothing is broken; Xcode is doing what it is designed to do with what it finds.

What happens, measured

We built Warmbuild’s fixture app (8 Swift files, one local Swift package with a C target) for the iOS Simulator with Xcode 27.2, with compilation caching off, and repeated the build in the situations CI creates:

Compile tasks per build
BuildSwift compilesC compiles
Cold, empty DerivedData282
Again, nothing changed00
Same folder, every file touched (what a checkout does)282
Same sources, DerivedData copied to another path282
Sources copied to another folder with their times kept, DerivedData copied along282

Counts are SwiftCompile and CompileC tasks in the xcodebuild log. The app is tiny, so read the counts, not the seconds (the cold build took 24 s, the touched one 5 s): what matters is that a restored DerivedData re-ran every compile.

Touching the files without changing a byte was enough to redo all the work. Four things are behind that.

1. Timestamps decide what is out of date

Xcode’s incremental build records each input file’s modification time (with nanosecond resolution) in DerivedData and redoes a task when an input’s time differs. Git does not store modification times, so actions/checkout writes every file with the time of the checkout. To the build system every source file is new, and every task that reads one runs again.

The workaround is to save the files’ modification times with the cache and put them back after checkout, only for files whose content is unchanged. The irgaly/xcode-cache action does exactly this (it stores times and SHA-256 hashes and restores matching ones). It works, with two conditions: the checkout path must stay the same, and you still pay for the size below.

2. DerivedData is full of absolute paths

Xcode’s default DerivedData folder is named after the project plus a suffix derived from where the project is, so a second checkout of the same repository gets a second folder and restores into one that is never used. Inside, build records, module caches, dependency files and the index point at absolute paths. Move either one, as in the last two rows of the table, and nothing matches, even with every file’s time preserved.

GitHub-hosted runners check out to the same path on every run (/Users/runner/work/<repo>/<repo>), so this matters less there than timestamps do. It matters for self-hosted runners with several workspaces, for jobs that check out with path:, and for every git worktree on a developer’s Mac or in a coding agent’s session.

3. It is big, and mostly not your code

After one Debug build of the fixture app:

DerivedData size by folder
FolderSize
SDKExplicitPrecompiledModules132 MB
Index.noindex34 MB
ModuleCache.noindex17 MB
Build (your products and intermediates)12 MB
Total196 MB

tar -czf took 11 s and produced 80 MB, for an app with 8 Swift files. A production app’s DerivedData is easily several gigabytes; check yours with du -sh ~/Library/Developer/Xcode/DerivedData/*. Every restore downloads and unpacks it, and every save packs and uploads it, before and after the build. If the build rebuilds everything anyway, that is pure overhead.

4. actions/cache works against it

  • Entries are immutable. “You cannot change the contents of an existing cache”, so an incremental cache needs a key per commit (github.sha) and a full new upload each time.
  • Branch scope. A run restores caches from its own branch or the default branch, never from a sibling branch. Two pull requests never share.
  • Limits. 10 GB per repository by default (more is billed), oldest-accessed entries go first, and entries unused for 7 days are removed. A few multi-gigabyte DerivedData entries evict each other and your package caches.
  • Stale restores. With restore-keys, the newest prefix match wins, which may be a build of different code with different settings. Even with times restored, much of it is then out of date.

What works instead

Cache Swift package checkouts, keyed by Package.resolved

Resolving and cloning packages is network work that a cache really does skip, and its key is exact. Put the checkouts in a folder of their own:

.github/workflows/build.yml
- uses: actions/cache@v6  with:    path: SourcePackages    key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }}    restore-keys: spm-${{ runner.os }}-- run: |    xcodebuild build -scheme App -destination 'generic/platform=iOS Simulator' \      -clonedSourcePackagesDirPath SourcePackages

Cache compile results by content: Xcode 26 compilation caching

Compilation caching (COMPILATION_CACHE_ENABLE_CACHING=YES, Xcode 26 and later) keys each compile by the content of its inputs, not by timestamps. In the same test with caching on and the cache kept outside DerivedData, touching every file still re-ran the 22 compile tasks of the project’s own code, but all 22 were cache hits. After deleting DerivedData entirely, the next build hit 218 of 218 keys.

The hard part is sharing that cache between runners: either save and restore it with actions/cache, with most of the costs above, or run a remote cache service that Xcode calls during the build. Xcode 26 compilation caching on GitHub Actions covers both routes in detail.

Where Warmbuild fits

Warmbuild is the second route as a hosted service: one step, uses: warmbuild/action@v1, before xcodebuild. The default branch fills the cache; pull requests, other runners and developers’ Macs read it, with paths mapped so their keys match. Nothing to save or restore. Set up the Action.

Build less

The cheapest compile is the one you skip: cancel superseded runs, filter by path, and run only the tests a change reaches. What macOS runner minutes cost goes through each with the workflow YAML.

Sources

  1. Measurements: Warmbuild’s fixtures/ios app, Xcode 27.2 on an Apple-silicon Mac, generic/platform=iOS Simulator, Debug, 15 November 2026. Rows 1–4 with COMPILATION_CACHE_ENABLE_CACHING=NO; the compilation caching figures with it on and COMPILATION_CACHE_CAS_PATH outside DerivedData.
  2. irgaly, xcode-cache: Xcode stores input modification times with nanosecond resolution in DerivedData; restoring them after checkout.
  3. GitHub Docs, Dependency caching reference: immutability, branch scope, 10 GB limit, 7-day eviction, key matching. Checked 15 November 2026.
  4. GitHub, actions/cache README (v6).
  5. Swift Forums, About Swift shared cache across machines: absolute paths, and why copying the compilation cache on CI is not the intended use.
  6. Apple, Xcode 26 release notes, “Compilation caching”.