Why Large Swift Projects Hit a Wall (And How to Break Through)
Large Swift codebases hit walls: slow builds, flaky tests, complex graphs. We dive into why Apple's toolchain struggles at scale and how teams can overcome these challenges without React Native or Bazel.
It's 2025, and developing Swift apps at a certain scale continues to be a challenge. If you are just getting started, or your project is just growing in code volume, number of modules, or number of developers contributing to it, you might not feel the need to make any changes to your setup. But if your codebase has a decent size, you are likely running into issues here and there that degrade your productivity and your motivation to contribute new features to an app. At Tuist, we see that every day.
We see many large organizations showing up seeking support, and many developers who, having experienced the support that Tuist gives them, don't think twice when they start new projects and bet on Tuist from the get-go.
In this post, I'd like to discuss the common challenges that we continue to see and how Tuist helps you overcome them. I'll also acknowledge some of the work that Apple has done and is doing to improve the state of things, and hopefully leave you informed about the many things you likely suffer from but can't pinpoint to the root cause. But before we dive into the details, let's first talk about what I like to call the wake-up leadership crisis.
Wake-Up Leadership Crisis
Apps usually are just another interface for the users or customers of a business to interact with the business domain, and a business's ultimate goal is to increase revenue and profit. If they move in competitive markets, this requires them to move at a faster pace, and this often leads to tension in iOS development teams where, regardless of how many human resources you throw at the problem, the bottleneck is the toolchain and everything that surrounds it. Your build times of 2 minutes or CI time of 5 years ago have become 30, and there's no Apple Silicon that can take those 30 back down to 2 minutes. And it doesn't matter how much blame you place on Apple—business doesn't care about that or about how much you've normalized those times. They deem it unacceptable, especially when they have other platforms like the web with much faster and more advanced tooling that gives them not only the speed they need but also visibility into what's happening to optimize the setup.
Having reached that point, we often see companies taking a few paths. One of the most common is adopting React Native. They throw a JS runtime into the mix, which allows them to hot-reload changes in development and even distribute updates and fixes over the air. Shopify migrated all their mobile apps to React Native for this exact reason. They had invested in React prior to this decision, and it made sense for them because it would also align mobile with the mental model they were already familiar with on the web. But you need to be mindful of the trade-off you are making here. By choosing React Native, you are abstracting away the native platform, and this comes at a cost that you can absorb yourself, as was the case with Shopify by assembling a team to develop and maintain foundational pieces, or rely on companies like Expo that package an ecosystem of libraries and services under an umbrella.
The second path that we see some companies taking is Bazel. Bazel is a very advanced and powerful build system. But replacing an entire build system is often even costlier than introducing React Native. Most programming languages and ecosystems come with some build system built-in: Elixir, Swift, Xcode, Gradle, Cargo... So when you replace it with something else, reconciling the build system piece with everything that builds upon it is a huge investment that not many companies are willing to make. At Tuist, we often see companies whose story goes like this: One or a few engineers get excited about Bazel, they convince leadership to spend a few months of work introducing Bazel, they manage to get it working although no one knows how it works except them, those engineers leave the company, and the company decides to drop Bazel because it's too costly. We've seen countless companies going through this path, and I completely get it. As much as you can, you should stay close to your build system because that also means staying close to the ecosystem that surrounds it.
For those who prefer to stay closer to the native toolchain and therefore have a lower cost to scale up their development, they see Tuist as the best option. This is by design. We abstracted just where we had to, such that we helped teams overcome the challenges while keeping an eye on and peeling abstractions when the issues were addressed at the right layer, like we saw with Xcode 16 and the introduction of buildable folders.
At this point, you might wonder: what are those challenges that large companies are facing, and that I might face too, if not today, maybe tomorrow? Let's explore some of them.
1. Modularization
Modularization is not just a tool for defining boundaries between components. It's also a tool to access reusable pieces of code across repositories and to share code between an app and the extensions contained in it. It's something that we'll do sooner or later. Modularization sadly comes at a cost, also related to an Xcode design decision.
As you might know, binaries can be linked statically or dynamically. Static linking happens at build time, while dynamic linking happens at runtime. The decision of one or the other for each module in the graph has implications that expand beyond just setting the linking type. For example, dynamic modules need to be explicitly copied into the right product so the linker can find them. Or static libraries will have to be linked from certain products that can link static libraries, ensuring we don't end up with duplicated symbols. Doing these things right is easy if there are a few modules in the graph, but as it grows in complexity, no human brain can hold a graph in memory to set things up and fix issues when they arise.
As with derived data, people feel this pain. And the pain was so strong that they decided SwiftPM would be the best solution to alleviate the pain. "These people have solved defining modules, so they must have figured out how to reconcile a SwiftPM's graph into an Xcode project's," the community must have thought. So without planning it, Apple found itself reconciling a SwiftPM graph with an Xcode project's graph, which might also contain dependencies that are implicitly defined. What a fun challenge that must have been for Apple. And if that wasn't challenging enough, they added an extra variable to make it even more challenging: mergeable libraries.
Over the past years, we've seen more and more companies outsourcing graph complexity to SwiftPM, just to realize soon after that they had to trade developer ergonomics to reduce the complexity of maintaining a graph. Now projects are resolved asynchronously, at times that you can't control, and a project that used to be ready to compile at launch no longer is because now it's invoking a SwiftPM process in the background whose process needs to be reconciled with Xcode's legacy business logic and UI. Fun, fun.
We've seen more and more companies outsourcing graph complexity to SwiftPM, just to realize soon after that they had to trade developer ergonomics to reduce the complexity of maintaining a graph
This is a complicated topic to bring into a discussion because one of the reasons why this happens is Swift itself and Package.swift manifests, which have to be compiled. Some of which can contain implicit side effects that they can't know but assume exist, as the pulling of sub-modules automatically shows. It's amazing writing manifests in Swift—Tuist does that, too—but when it comes at the cost of degrading the developer experience at a certain scale, I wonder if that's a good idea. For comparison, Cargo, Rust's build system, uses serializable .toml files. The JavaScript ecosystem uses .json, and Bazel uses the Starlark language, which is domain-specific and optimized for build systems.
For Tuist, making modularization extremely simple was our main goal, along with avoiding frequent git conflicts, from the get-go. It was the thing that set us apart from other generation tools and also the foundation that we needed to optimize teams' projects. I might be biased here, but Tuist is the best modularization tool out there. SwiftPM was designed for resolving dependencies, does a decent job at that, and should remain as a tool for that. Xcode projects should evolve to ease modularization such that SwiftPM is not used as a project manager. This is a symptom that Xcode and Xcode projects require a design iteration. Until then, I think generating projects like Tuist does is the best path forward. Anything required to get the linking right, from build phases to build settings, is all taken care of for you, such that you can focus on the graph structure and not on the implementation details that make it possible within an Xcode project.
2. Build Times
Every new year, new Apple Silicon comes out, and your managers rush to put a case to leadership to buy them because they might help make a cut in the build times of your projects. But the cuts got smaller and smaller over time. Justifying the investment got harder and harder over time, and you need an alternative. Unfortunately, React Native and Bazel are not an option, and sadly, Xcode doesn't help you with that.
This is the main driver of organizations adopting Tuist. They want their build times to be faster. Depending on whom you ask what "slow" is, you get different answers, but we've seen them ranging from "I'm tired of my 5-minute builds" to "I'm tired of my 2-hour builds on CI." Those times have been terribly normalized in the ecosystem. And they get worse when people clean derived data to mitigate an Xcode issue that they don't understand. "Can't they multi-task?" you might wonder. As if multi-tasking is the answer to everything. Multi-tasking means context-switching, and context-switching is terribly expensive too.
Soon after having made modularization easier with generated projects, we noticed we had the right foundation to optimize build times, learning from projects like Carthage or Bazel, and mutating the graph to incorporate binaries in it and ensuring our generation logic would reconcile all the changes. The result? A caching system that helps teams achieve up to 80% improvements (e.g. Trendyol achieved 65%) in their build times. The effectiveness depends strongly on the modularization and how the contributions distribute across all the modules. At the very least, you'll get all your third-party dependencies cached. Because who wants to be compiling code that you barely change on clean builds?
You can check out Tuist's public dashboard to see how our current cache effectiveness moves around 84%. Or take, for example, this test run where 122 modules were replaced with binaries. We hope that caching will eventually come natively to the native build system, and CAS is the right step in that direction. At Tuist, we are preparing for that by building the fastest and lowest-latency solution so that people can plug their Xcode projects in the near future without any hassle.
3. Test Times
Building is just part of what teams do after changes are implemented. They also need to run tests. Locally, developers usually run the tests that have changed or those that are connected with the file changes. However, on CI, teams commonly run all the tests of the test suite. And this means that the more tests they add, the slower they'll take to run on CI, and at some point, you'll have to do something about it.
We touched on some strategies here, but the general idea is that you should first consider parallelizing within the environment, since CI environments give you access to all the Apple Silicon cores. This comes with the challenge of ensuring your code is designed for parallelization, which is often not the case, manifesting as race conditions that appear as flakiness or, even worse, data races. If that's not enough, you can then parallelize across environments, but this requires doing some sort of sharding of tests across environments and playing with the -build-for-testing
and -test-without-building
flags. Sharding is something we plan to solve at Tuist such that developers can automatically do it by using flags with the CLI instead of having to write their own script. If this is not enough, the last thing that you can do is test selection, which consists of selecting the tests that should run based on the file changes.
At Tuist, we provide selective testing, which works both with generated projects and vanilla Xcode projects and makes use of our fingerprinting logic that's Git-agnostic and has been battle-tested by many large organizations with complex Xcode projects. The adoption is quite straightforward:
4. Insights
You build or run tests in your Xcode project, Xcode's UI presents you with the result, and that data remains there, in your environment, as if nothing happened, until you accidentally clean it with your deletion of derived data. Things look no different on CI. Your builds run, and the output from your builds is two things: whether it passed or not and xcodebuild's logs, often formatted using formatting tools like xcbeautify. The isolation and ephemeral character of that information prevent the big picture that teams need to answer these questions: Are my builds getting faster or slower? And what about my tests? Is my app growing in size? Are there any new flaky tests? In other ecosystems like the web, collecting, processing, storing, and analyzing data is a common practice. In Apple's, it's not, despite its importance in making informed decisions to improve the development setup.
As for the reasons for this not being that common, I have a few guesses. The first one is that the data exported by Apple's toolchain is very proprietary, which adds an extra level of indirection requiring some processing pipeline before the data can be exported to systems to analyze it. The .xcresult
and .xcactivitylog
are good examples of that. That was never data designed to be consumed outside of Xcode and therefore lacks documentation and the tools to process it. The second reason I believe is that storing the data requires a database, which in some cases might need to be more than just a standard SQL database—for example, ClickHouse—and a server that can authenticate and authorize access to the database. These are systems that, first, iOS teams don't usually have the familiarity with and the eagerness to build and maintain, and second, struggle to get leadership's support to do. Therefore, they end up with this being a hobby project within the company that often takes the shape of tinkering with Swift in the server environment. It's impressive what teams out there have been able to build, but it's not enough.
For data to be useful, you also need to present it in a way that's useful. You also need to correlate data to derive new useful information. And also build a system such that you can define tripwires and help teams monitor if things don't go as planned. It's a lot. And this is what we are building with Tuist such that the leadership of those companies can outsource that to Tuist and let their engineering resources be focused on building the apps. You can check out our read-only public Tuist dashboard to get a sense of the data that you'd be able to get for your teams. For example, with one scheme post action, you can get build insights in the dashboard, and soon you'll be able to do the same with your test results.
You can continue working blindly, and you might go a long way with it, but without data, you'll have a hard time having conversations with your leadership, especially when times like "I've heard of React Native; what do you think?" come.
5. Derived Data
Who doesn't know derived data? That folder that everyone deletes when compilation fails. The same folder whose deletion leads to a clean build, which can be quite slow in some cases. Derived data proved to be a bad decision whose effects are felt in the long run. A decision that has made many projects in the wild compile not because they are explicitly importing dependencies, but because they can accidentally find them in derived data. Implicit imports can lead to editor features such as macro expansions or Swift previews working unreliably because the editor resolves an invalid graph, causing missing binaries to be absent in derived data for those editor features to work. As crazy as it sounds, it's all rooted in derived data, but we don't talk much about it—not enough, in my opinion. We joke about previews not working or cleaning derived data here and there, but the problem is indeed serious.
The good news is that Apple is aware of it and is working on it through a different approach called content-addressable store (CAS) and explicit modules, which they are gently pushing people toward. So instead of having one shared directory, the build system uses hashes to look up compilation artifacts from previous compilations and uses them instead of invoking the compiler. This works in cases where your project has no implicit dependencies. And here's the thing: rolling out such a system requires Apple not only to introduce explicit modules but also to nudge the ecosystem to adopt them. In my opinion, the push has been too gentle and lacked a bit of the "why it's needed." Perhaps because doing so requires acknowledging a past design decision that led to derived data becoming a problem. I think it's fine. I'd rather see Apple being open and transparent about it and how they are approaching it than discussing it internally and leaving us making the connections between some Xcode features and the reasons why they might be introducing them.
Tuist-generated projects can't prevent implicit dependencies, but the explicitness of declaring dependencies makes them a bit less likely. Because of that, we introduced a command that developers can run against their generated projects to check for implicit imports using static code analysis, and this is something I strongly recommend to everyone.
As I mentioned earlier, my bet is that the reliability of many Xcode features is strongly correlated with the explicitness of the project's graph, so the less implicitness you have, the better. Sadly, this is not evangelized enough, neither by Apple nor by the community, so it often feels like sending messages into the void.
Closing Thoughts
At Tuist, we are building the native platform for scaling Swift app development so that you don't have to. There are problems that are on Apple to solve, and we are excited to see them implicitly acknowledging some of them and throwing some resources at them. And there are other problems that require some awareness first from your side, and this is what we aim to create with these posts, and solutions like Tuist that take some of the burden off your plate such that adopting them is just one line of code in your project, instead of having to maintain a complex system.
These solutions happen to make any agentic coding experience even better, because at the end of the day, any pain experienced by humans will be more annoying through agents. From slow feedback loops in compilations to lack of information to inform the coding sessions. That's why we deem crucial in the platform that we are building explicitness and openness of the data that we are working with such that agents have access to it at any time.
Scaling Swift app development happened to be the challenge we got obsessed with and that we continue to be excited about. If any of the challenges resonated with you and you'd like to share them with us, send us an email at contact@tuist.dev and let's chat. We are here to help any organization out there because we want organizations to be more effective at their missions.