DEV Community

Cover image for My iOS App Got 22% Smaller With One Xcode Setting
Hitarth Bhatt
Hitarth Bhatt

Posted on

My iOS App Got 22% Smaller With One Xcode Setting

Modularizing made our app bigger and slower to launch. One build setting won it all back, with zero code changes.

We didn't write a single new line of code. We just moved some of it into frameworks, like every modularization guide tells you to.

Then we archived the release build, and the app had gotten bigger.

Confused math lady

This happened on a team I worked with, and it took us a week to crack. Here's the case file. I rebuilt the crime scene as a demo app, so every number below is real and reproducible.

πŸ—‚οΈ CASE FILE: The App That Gained Weight

  • Victim: MyApp, freshly modularized
  • Symptom: got bigger after we only moved code
  • Suspects: Xcode Β· the CI cache Β· Mercury in retrograde Β· public
  • Fix: one build setting, 22% smaller, 0 frameworks loaded at launch

πŸ” Exhibit A: 3 KB of Code in a 73 KB Box

All three modules are plain dynamic frameworks, which is what Xcode gives you by default. Now look at SupportFeature: about 3 KB of code shipped as a 73 KB binary.

That's a USB cable shipped in a microwave box.

Every dynamic framework is split into four segments, and on arm64 each segment is padded to a 16 KB page, even when it's nearly empty. So each framework pays about 64 KB of packaging before it holds any code. On top of that, dyld has to find, map, and bind every framework separately each time the app launches.

Using CocoaPods with use_frameworks!, or dynamic SPM products? Then every dependency is one more passport check at airport security on every cold launch. That's why "I added a few pods and my launch got slower" is such a popular Stack Overflow question.

This is fine

Padding explains the tiny frameworks. It doesn't explain why CoreKit is 316 KB when it holds only 88 KB of code.


🧀 Exhibit B: The Fingerprints

When you move code out of the app target, the compiler greets you with this error, about four hundred times:

'EventName' is inaccessible due to 'internal' protection level
Enter fullscreen mode Exit fullscreen mode

So you do what everyone does:

public struct EventName {
    public static let appOpen = "App Open"
}
Enter fullscreen mode Exit fullscreen mode
Scooby-Doo unmasking

Here's the interrogation transcript:

πŸ§‘β€πŸ’» Me: Nobody even calls appOpen. Why didn't you strip it?

πŸ”— Linker: It's public. Someone outside the framework might call it.

πŸ§‘β€πŸ’» Me: Who?

πŸ”— Linker: …someone. I also kept its type metadata and added it to the export table. You're welcome.

Every public symbol costs you in three places:

  • Code the linker can't dead-strip (__TEXT)
  • Type metadata (__const)
  • An entry in the export table (__LINKEDIT), which is the framework's phone book

To prove it, I built the same CoreKit (360 constants and 40 models) twice. The only change was the access level:

CoreKit public internal
Binary 316 KB 221 KB
__LINKEDIT (phone book) 112 KB 48 KB
Exported symbols 3,518 36

Same code, 30% smaller. The phone book was bigger than the town.


πŸͺ€ Exhibit C: The False Leads

We spent a week refactoring our way out of it:

public struct EventName { ... }                      // a struct
public enum EventName { ... }                        // no, an enum
public enum EventName: String { ... }                // enum cases!
public enum Constants { public enum Event { ... } }  // nest it
public enum ConstantsEventName { ... }               // flatten it
Enter fullscreen mode Exit fullscreen mode

The size report after each one:

They're the same picture

The shape of the type doesn't matter. The code is still public, and the framework is still paying its packaging tax. The real fix is fewer public declarations, but that's a quarter of cleanup work. We wanted a quicker win, so we asked a different question:

What if the frameworks didn't have to stay separate in the build we ship?


πŸ’‘ The Breakthrough: Why Not Both?

Dynamic frameworks build fast but launch slow. Static libraries launch fast but build slow. Xcode 15's mergeable libraries looked at that trade-off and said:

Why not both

Here's the whole idea in one picture:

  • πŸ› οΈ Debug: nothing changes. The app re-exports each framework, so incremental builds stay fast.
  • πŸš€ Release: the linker merges every framework into the app binary and de-duplicates strings, selectors, and stubs along the way. The framework folders stay in the bundle for resources, but their binaries become empty shells.

πŸ”§ The Fix: One Setting

Select your app target β†’ Build Settings β†’ search for Create Merged Binary β†’ set it to Automatic.

MERGED_BINARY_TYPE = automatic
Enter fullscreen mode Exit fullscreen mode

That's it. No code changes, no new tools. automatic merges every direct framework dependency built in your project.

⚠️ One gotcha: only direct dependencies get merged. If CoreKit reaches your app only through SupportFeature, link it to the app target explicitly.

πŸŽ›οΈ Want precise control? Set MERGED_BINARY_TYPE = manual on the app and MERGEABLE_LIBRARY = YES on each framework you want merged.


βš–οΈ The Verdict

MyApp.app (Release) Before After
Total size 560 KB 436 KB (βˆ’22%)
App executable 73 KB 305 KB
CoreKit executable 316 KB 33 KB
Frameworks dyld loads 3 0
Exported symbols 3,518 1

The app executable grew because all the code moved into it. The framework binaries left behind are empty shells.

That took one build setting and zero code changes. The demo has three tiny frameworks. Real apps have dozens.


πŸ“œ The Fine Print

With great power comes great responsibility
  • Debug β‰  Release. Merging happens only in Release builds, so test the build you actually ship.
  • Archives take longer. Merging is extra linker work, so measure the impact on big apps.
  • Manual mode is easy to forget. Put MERGEABLE_LIBRARY = YES in a shared .xcconfig.
  • Edge cases:
    • Bundle(for:) on merged frameworks needs iOS 12 or later.
    • dlopen paths need to point at the merged binary.
    • Crash reports list the app as the image.

βœ… Worth it if you have more than a handful of first-party frameworks and someone watches app size or launch time. ❌ Skip it if you have one or two frameworks, or you ship binary SDKs.


βœ… Case Closed

Your homework:

  1. Run nm -gU YourFramework | wc -l, and brace yourself.
  2. Change one thing that doesn't need to be public back to internal.
  3. Set MERGED_BINARY_TYPE = automatic on a branch and compare the archives.

πŸ‘‰ Grab the demo repo on GitHub: the measuring scripts behind every number in this post.

πŸ“Ί Linker deep dive: Meet mergeable libraries (WWDC23)

I blamed Xcode, the CI cache, and the planets before I found the six-letter keyword I'd been typing on autopilot for years. Now I run nm before I blame anything.

Mic drop

Send this to the teammate who just created their fifth framework this sprint.

Top comments (0)