At Pixelmatters, we’re used to helping clients scale products that have already outgrown their original architecture. This was the case with our client ClearanceJobs, where what started as “just an app” now includes live streaming, a busy social feed, real-time chat with attachments, and a full job and company search experience. Unsurprisingly, the codebase grew along with it.
From the compiler’s point of view, though, all that growth eventually collapsed into one thing: a massive monolith.
On paper, things looked good. The project was modular, split across multiple Xcode targets. We had an Engine layer to glue everything together and an App target responsible for UI. It was easy to test, easy to scale, and nicely separated by responsibility. Early on, builds were fast and iteration felt smooth. Then the product kept evolving… and build times quietly got worse.
At its peak, a clean build took anywhere between five and ten minutes. Trigger that a few dozen times a day and you’re easily burning an hour and a half just staring at Xcode. If you’re not a mobile developer, five minutes might not sound like much, but when you’re iterating on UI, fixing tiny layout issues or tweaking copy, those minutes add up fast. Before long, you’re losing almost a full workday every week to compilation alone.
Morale takes a hit, pressure builds and suddenly the most frustrating part of the job is pressing Build.
This is the story of how we cut our iOS build times down to ~30 seconds by rethinking our modularity, moving to Swift Packages, and reclaiming our sanity along the way.
How we got here
This app had been around for years and evolved alongside the business, which made plenty of sense at the time:
One long-running iOS app with lots of features living together;
No dedicated API built specifically for mobile;
A tech stack that reflected old constraints more than current needs;
A fast-moving product that outgrew its original architecture.
Mobile moves quickly. Architectures that don’t evolve with it slowly start to hurt, usually in ways you don’t notice until it’s painful.
Modular project from day zero
From the start, we split the codebase into multiple Xcode targets with clear responsibilities. The goal was to apply SOLID principles not just at the class level, but at the module level too.
Targets had well-defined roles (Engine, UI, Services, etc.), dependencies flowed in one direction only and each module had strong test coverage. This made it easy to reason about changes, test behavior in isolation and keep responsibilities clean.
We also leaned into SwiftUI early, even when it was still finding its footing. Security constraints pushed us to keep third-party dependencies to a minimum and we only brought in external frameworks when there was a strong justification (Firebase being a good example).
We ended up with a modular project where all targets were tied to a single monolithic Engine module that handled all communication with the app. That detail would come back to bite us.
Inside the Module Architecture
SwiftUI helped us move fast, and we did. Screens multiplied. Components grew. Navigation flows became more complex.
Each screen followed a consistent pattern: a loader talking to services, an adapter bridging layers like navigation, a presenter shaping data for the view, and a SwiftUI view composed of reusable components. Those components were coordinated through a Store and ViewModel that managed state and layout constraints.
It was structured. It was consistent. It scaled well, functionally.
Where things started to hurt
The real pain showed up inside the App target. Over time, it became a grab bag of:
A growing design system;
An ever-expanding list of features;
App-level glue code.
As that target grew, so did the cost of touching anything inside it.
SwiftUI previews were often forced to compile a huge chunk of the app before showing a single view. Shared UI changes triggered rebuilds across dozens of screens. Even small tweaks could invalidate a large dependency graph.
In short: the architecture still looked good, but the build system cost had changed. What worked perfectly when the app was smaller became a serious bottleneck once the UI exploded in size.
Investigating the bottleneck
At some point, ignoring it just wasn’t an option anymore.
We started digging into build logs, comparing compile times, using Xcode’s tools to understand what was actually happening. The conclusions were clear:
Project targets were being recompiled far more often than expected;
Design system and features lived in the same giant target;
SwiftUI previews regularly triggered full app builds;
Even trivial changes could lead to 10-minute compiles;
Developer time (and CI credits) were being burned for no real value.
The architecture wasn’t wrong. It just wasn’t keeping up with the scale of the product anymore.
Improving Performance
We didn’t stop everything and rewrite the world. We started where the pain was loudest.
SwiftUI previews were the biggest daily frustration. Working on UI without fast previews is challenging, and avoiding previews entirely just slows you down in other ways. So the goal to make previews fast again became clear.
We set a few simple rules:
Create dedicated UI modules so previews don’t depend on the full app;
All new features must use the new structure;
Touching old code? Migrate it while you’re there;
Make sure the product team understands that inaction also entails a cost.
Once a home screen preview depended on a small, focused module instead of the full app target, previews went from minutes to almost instant.
That success pushed us further. We started looking at Swift Packages as a better boundary for modularity. Unlike large Xcode targets, packages compile in isolation and benefit from much stronger caching. If a package hasn’t changed, Xcode can often reuse it without rebuilding.
The Migration Plan
Because we already had good test coverage and clear module boundaries, we could migrate gradually. We tracked dependencies carefully, moved one module at a time, and left the core Engine for last.
No big-bang rewrite. Just consistent and deliberate progress.
The payoff was huge. Build times dropped dramatically. Previews were usable again. The team was happier, less frustrated, and able to focus on actual product work instead of watching progress bars.
The final numbers were amazing:
Good is not good enough
The biggest lesson here isn’t about Swift Packages or build times. It’s that architectural decisions age.
What solves today’s problems can quietly become tomorrow’s bottleneck. Our Xcode targets were a smart choice at the time, but also a serious drag on productivity later.
When developers start dreading the compile button, that’s a signal worth listening to.
Revisit old decisions. Ask whether solutions that were once “good enough” are still the right ones today, or whether there’s room to improve. Make space for experimentation. A few hours exploring better tooling can save weeks of frustration down the line.
Final Thoughts
You don’t need a revolution. You just need to start where it hurts.
For us, that meant fixing SwiftUI previews first, splitting UI into Swift Packages, and slowly shrinking the impact of a monolithic Engine.
A few takeaways we’ll carry forward:
Small, consistent improvements compound;
Bottlenecks matter more than theoretical perfection;
Legacy code isn’t the enemy, inertia is;
Happy developers build better products.