Back to Blog

Xcode build times: what actually helps

Xcode build times improve when teams reduce work, make dependency invalidation boring, split targets with discipline, measure clean versus incremental builds, and stop treating DerivedData folklore as a strategy.

11 min read

Xcode build times are one of those problems teams love to discuss after they have already normalized the pain.

A clean build takes twenty minutes. Incremental builds randomly take two. The app target depends on everything. A Swift package change detonates half the workspace. Somebody deletes DerivedData like a ritual sacrifice and calls it engineering.

Build performance does not get better because the team buys a faster Mac and hopes the silicon forgives the architecture.

Faster hardware helps. So do newer Xcode versions, better CI runners, and not running the entire company on an Intel machine under a desk. But those are multipliers. They multiply the shape of the project you already have.

If the project is a dependency hairball, Xcode will build the hairball faster. It is still a hairball.

The useful question is not “how do we make Xcode fast?”

It is:

How do we make Xcode do less unnecessary work, more predictably?

That is where the real wins are.

1. Measure clean and incremental builds separately

A team that talks about “the build time” usually has not measured enough.

There are at least three different problems:

  1. clean build time
  2. incremental build time after a local edit
  3. CI build time on a fresh runner

They have different causes.

A slow clean build often points to too many dependencies, large modules, expensive code generation, or bad caching. A slow incremental build usually means the edit invalidates too much of the graph. A slow CI build may be mostly checkout, package resolution, simulator setup, or cache misses wearing an Xcode costume.

Measure them separately before changing anything.

At minimum, track:

  • clean app build duration
  • no-op build duration
  • incremental build after editing a leaf feature
  • incremental build after editing shared UI
  • incremental build after editing a core model
  • test build duration
  • time spent resolving dependencies

A no-op build should be boring. If it is not, you have scripts, generated files, or build phases doing work that they cannot prove is necessary.

That is not a compiler problem. That is a project hygiene problem with a stopwatch.

2. Stop making every edit invalidate the world

Incremental build performance is mostly about blast radius.

If changing a button style rebuilds networking, persistence, analytics, feature flags, and the app target, the module graph is telling you something rude and accurate.

The fastest build is the one Xcode can avoid.

Good build graphs have direction:

  • feature modules depend on shared foundations
  • shared UI does not depend on feature code
  • persistence does not import the app shell
  • networking does not know about SwiftUI screens
  • analytics does not become a global dependency magnet

Bad build graphs are circular conversations with more targets.

The common mistake is splitting code into modules without reducing coupling. That gives you more build settings, more package overhead, and the same invalidation problem in a nicer hat.

A useful module boundary has two properties:

  1. it hides implementation details
  2. it prevents unrelated changes from rebuilding unrelated code

If a module does not do either, it is probably organizational theater.

3. Keep the app target thin

The app target should assemble the app.

It should not be where every feature, service, design token, extension, generated client, experiment, and “temporary” helper lives until the heat death of the repo.

A fat app target hurts because it sits at the top of the graph. When it changes, everything important gets involved. When everything important changes inside it, Xcode has no cheap boundary to reuse.

A healthier shape looks like this:

  • App wires dependencies, scenes, navigation, and environment
  • Features/* owns user-facing flows
  • DesignSystem owns reusable UI primitives
  • Networking owns transport, clients, and request policy
  • Persistence owns storage and migrations
  • Core owns tiny shared types that are genuinely stable
  • TestSupport owns builders, stubs, and fixtures

This is not a plea for micro-modules. Tiny modules can make builds worse if they create package overhead and dependency chaos.

The point is to pull stable, testable, reusable code out of the app target so normal edits have a smaller surface area.

A good app target should be almost boring to compile. If it feels like the entire product is being rebuilt every time, it probably is.

4. Be suspicious of giant Swift files and clever generics

Swift compile time is not only about the number of files.

It is also about what the compiler has to understand inside them.

The usual offenders:

  • huge SwiftUI body expressions
  • deeply nested result builders
  • complex generic constraints
  • overloaded helper APIs that make type inference work too hard
  • massive files full of unrelated types
  • protocol-heavy abstractions with conditional conformances everywhere

SwiftUI is especially good at producing code that looks elegant and compiles like a tax audit.

If a view body is enormous, split it. Not because small functions are morally superior. Because smaller expressions give the compiler fewer opportunities to stare into the abyss.

Prefer this:

var body: some View {
    VStack(spacing: 16) {
        header
        content
        footer
    }
}

Over one heroic body that contains every conditional, animation, layout branch, and modifier chain since the founding of Cupertino.

This helps humans too, which is rude of performance work to be so practical.

When builds feel mysteriously slow, enable timing diagnostics and find the actual hot spots instead of blaming “Swift” in the abstract. Slow expressions are often local. Fixing three of them can matter more than reorganizing a folder tree for two days.

5. Treat build phases like production code

Build phases are a favorite place to hide expensive nonsense.

They run because someone added a script years ago, the app still builds, and nobody wants to touch it because it has the emotional energy of a haunted shell script.

Audit them.

Every script phase should answer:

  1. what input files does it read?
  2. what output files does it write?
  3. can Xcode skip it when inputs did not change?
  4. does it run in Debug, Release, or both?
  5. does it need network access?
  6. does it mutate tracked files?

If a script has no declared inputs and outputs, Xcode has to assume it matters every time. That is how a two-second script becomes a permanent tax on every build.

Good build phases are deterministic and skippable.

Bad ones do things like:

  • generate files on every build even when sources did not change
  • call package managers during the build
  • format code as part of compilation
  • download remote assets
  • run broad shell scans over the repository
  • update version files in a way that dirties the working tree

That is not automation. That is a small CI outage wearing local clothes.

6. Make generated code boring

Generated code can be excellent for build performance when it replaces runtime reflection, stringly-typed glue, or duplicated boilerplate.

It can also make builds miserable.

The difference is usually invalidation.

If code generation rewrites a large file on every build, every downstream target pays. If it produces unstable ordering, timestamps, or formatting churn, Xcode sees change even when the meaning did not change.

Generated files should be:

  • stable between identical inputs
  • split by feature or schema when possible
  • checked in or regenerated consistently, not both randomly
  • produced before compilation, not halfway through it
  • small enough that one API change does not rebuild the universe

Do not generate one enormous API.swift file because it was easy for the generator author. Easy for the generator can be expensive for everyone else.

Also, commit generated code only if the team has a clear reason. It can speed clean builds and make diffs explicit. It can also create review noise and merge conflicts that make everyone miss handwritten code with fewer opinions.

Pick one policy. Enforce it. Drifting between policies is where the comedy starts.

7. Dependencies need ownership, not vibes

A dependency is not free because Swift Package Manager installed it politely.

Every package adds some combination of:

  • resolution time
  • checkout time
  • compilation time
  • binary size
  • transitive dependency risk
  • cache invalidation
  • upgrade work

That does not mean avoid dependencies. It means stop treating them like stickers on a laptop.

For each dependency, know:

  1. who owns it?
  2. why is it worth its build cost?
  3. how often is it updated?
  4. does it compile source or ship as a binary artifact?
  5. does it leak into many modules or stay behind one boundary?

The worst dependency is a convenience package imported everywhere for two functions. Now every feature target knows about it, every build graph includes it, and removing it later becomes an archaeological project.

Put dependencies behind boundaries.

If only networking needs a package, keep it in networking. If only one feature needs a charting library, do not let it become part of Core. Core is where dependencies go to become everybody’s problem.

8. Do not worship DerivedData

DerivedData is a cache, not a religion.

Deleting it sometimes fixes a broken local state. Fine. So does turning something off and on again. That does not make power-cycling an architecture.

If the standard advice for build problems is “delete DerivedData,” the team is avoiding root cause.

Ask better questions:

  • why did the cache become invalid or inconsistent?
  • did generated files change without Xcode knowing?
  • did build settings change without the cache key reflecting it?
  • are scripts writing into build directories incorrectly?
  • are multiple Xcode versions sharing assumptions they should not?
  • is the package cache stale or genuinely corrupt?

On CI, cache keys matter more than cache enthusiasm.

Include the things that actually change build outputs:

  • Xcode version
  • SDK version
  • Package.resolved
  • build configuration
  • destination platform
  • relevant project or package manifests
  • code generation inputs

A broad cache key gives you mystery speed followed by mystery failures. That is not a tradeoff. That is buying time with future debugging.

9. Use binary targets carefully

Prebuilt binaries can reduce build time dramatically for large stable dependencies.

They can also create a different class of pain: architecture mismatches, debugger limitations, symbol issues, slower iteration on the dependency itself, and awkward release workflows.

Use binary targets when the dependency is:

  • large
  • stable
  • slow to compile
  • not edited by the app team daily
  • distributed with reliable symbols
  • compatible across the platforms you ship

Do not turn every internal package into a binary artifact because one clean build was slow on a Friday.

Binary distribution is operational work. Versioning, symbols, source maps, privacy manifests, licenses, and CI publishing all become part of the system.

For third-party SDKs, a binary can be a gift. For fast-moving internal code, it can be a handbrake painted gold.

10. Keep Debug builds debug-friendly

Teams sometimes make Debug builds slow by dragging Release behavior into them.

Debug should optimize for iteration:

  • avoid unnecessary asset processing
  • disable expensive optional validation unless requested
  • skip release-only upload steps
  • avoid whole-module optimization when it hurts local changes
  • keep code signing as simple as the platform allows
  • use local fixtures instead of remote setup where possible

Release should optimize for shipping.

These are different jobs. If Debug has to do every release ritual before the app launches, local development becomes a bureaucratic experience.

The trap is letting “we should validate this” become “every developer must pay for this every time.”

Some checks belong in CI. Some belong in release lanes. Some belong behind an explicit command. Not every noble concern deserves to sit in the hot path.

11. Fix the project before blaming the machine

A faster Mac is nice.

So is a better CI runner, more RAM, a newer Xcode, and a clean simulator runtime that does not behave like it has unresolved childhood issues.

Use them. Just do not mistake them for the main strategy.

The durable wins are usually less glamorous:

  • smaller invalidation boundaries
  • thinner app targets
  • deterministic build phases
  • stable generated code
  • disciplined dependencies
  • measured compiler hot spots
  • useful cache keys
  • separate Debug, PR, and Release concerns

Build time is developer experience, but it is also architecture feedback.

If Xcode rebuilds half the product for a small edit, listen. The compiler is not being dramatic. It is showing you the shape of your codebase.

Make that shape boring.

Boring builds are fast enough, predictable enough, and rarely discussed in meetings.

That is the dream: not a magical project where builds take zero seconds, but a grown-up one where nobody loses the afternoon because a script touched a file, a package leaked into Core, and DerivedData was blamed for crimes committed by the dependency graph.