Home

Cutting a 4-minute Xcode build to 50 seconds

A clean build was four minutes. An incremental build after changing one line was ninety seconds, which is long enough to lose your train of thought and open a browser tab.

Most of it turned out to be the Swift type checker, and finding out which lines were responsible took one build setting.

Measure first

Add these to Other Swift Flags in build settings:

-Xfrontend -warn-long-function-bodies=100
-Xfrontend -warn-long-expression-type-checking=100

That emits a warning for any function body or expression taking longer than 100ms to type-check. Build, then sort the issue navigator by that warning.

The first time I did this, one expression took 8.4 seconds. On its own. In a file I edited daily.

The three patterns that caused most of it

Mixed-type arithmetic in one expression. This is the big one:

// 8.4 seconds
let spacing = (width - padding * 2 - CGFloat(items.count - 1) * gap) / CGFloat(items.count)

Every numeric literal in Swift is generic over ExpressibleByIntegerLiteral, and every operator is overloaded. The type checker has to consider every combination of Int, Double, CGFloat and the rest for every sub-expression, and the search space is exponential in the number of terms.

Breaking it up with explicit types collapses the search:

// 40ms
let count = CGFloat(items.count)
let totalGaps: CGFloat = (count - 1) * gap
let available: CGFloat = width - padding * 2 - totalGaps
let spacing: CGFloat = available / count

Same arithmetic, four statements, two hundred times faster to compile. It also reads better, which made this an easy sell to myself.

Collection literals without a type annotation.

// slow: the checker must infer the element type from all elements
let config = ["timeout": 30, "retries": 3, "backoff": 1.5]

// fast
let config: [String: Double] = ["timeout": 30, "retries": 3, "backoff": 1.5]

Long + chains on strings or arrays. Same problem as arithmetic — + is heavily overloaded. String interpolation is both faster to compile and easier to read.

Tip

The rule that covers all three: annotate the type when an expression has more than about three terms, or mixes numeric types. Type inference is a convenience, and it is one that gets superlinearly expensive. Writing : CGFloat costs you nothing.

Modularisation, and what it actually bought

Splitting the app into local Swift packages is the advice everyone gives. It helped, and less than I expected.

The win is that Xcode does not rebuild a module whose sources did not change, so editing a view only rebuilds the feature module and the app. On this codebase, incremental builds went from 90 seconds to about 25.

The cost is real: local packages need their dependencies declared explicitly, previews sometimes break in ways that are hard to diagnose, and a circular dependency between two feature modules is a refactor rather than a warning.

My advice would be to do the type-checker work first. It is a few hours, it needs no architectural change, and on my codebase it was the larger of the two wins.

The build settings worth checking

SWIFT_COMPILATION_MODE should be incremental for Debug and wholemodule for Release. If Debug is set to whole-module, every change rebuilds every file in the module — I found this on a project where someone had set it globally to “make Release faster”.

DEBUG_INFORMATION_FORMAT should be dwarf for Debug, not dwarf-with-dsym. Generating a dSYM on every debug build is pure waste; you need it only for release symbolication.

ONLY_ACTIVE_ARCH should be YES for Debug. Otherwise you are building for architectures you are not running.

ENABLE_PREVIEWS — worth knowing that previews build additional variants, and turning them off in a scheme you use for CI or profiling shaves time.

What else helped

Reducing @_exported import. These make everything downstream depend on everything upstream, which defeats incremental compilation across module boundaries.

Explicit private and fileprivate. Beyond the readability argument, it lets the compiler skip work when checking what could have changed. Marking classes final helps for the same reason.

Removing dead code. Obvious and easy to postpone. About 4,000 lines in my case, and the compiler was type-checking all of them.

The result

Before After
Clean build 4m 10s 1m 20s
Incremental (one view) 90s 12s
Incremental (one model) 90s 25s

The single largest contributor was the type checker work — about half the total improvement, from a morning of adding type annotations to roughly forty expressions.

If you do one thing from this: turn on the two warning flags and look at what comes out. It is five minutes, and the list will surprise you.