Static vs Dynamic Framework Dispatching (Part 2 of 3): SPM vs Xcode frameworks

Static vs Dynamic Framework Dispatching (Part 2 of 3): SPM vs Xcode frameworks

Observing What Actually Happens at Runtime

This is Article 2 of 3 in the series Static vs Dynamic Framework Dispatching.

In Article 1, we built a mental model of how static and dynamic frameworks are loaded and dispatched:

In this article, we stop theorizing and start measuring.

We will:

  1. Build the same SDK in static and dynamic forms
  2. Link them using SPM and Xcode Framework targets
  3. Inspect the produced binaries with otool, nm, size, and lldb
  4. Observe how dispatching changes:
    • what is embedded
    • what is loaded by dyld
    • what is resolved lazily

This is not about “what Apple says”. This is about what your binary actually contains.


TL;DR

  • nm confirms static framework symbols are inside the app binary (type T) — dynamic framework symbols are not.
  • otool -L shows dynamic frameworks as separate loaded images — static ones don’t appear at all.
  • otool -l | grep -c __got1 for dynamic, 0 for static — GOT entries prove dyld patching at launch.
  • Every dynamic call goes through a symbol stub in disassembly; static calls are direct bl instructions.
  • SPM and Xcode framework targets produce identical Mach-O dispatch patterns — the build tool doesn’t matter.
  • lldb image list shows dynamic frameworks as standalone dyld images; static framework code is invisible — merged into image [0].
  • DEBUG builds inject a .debug.dylib — an Xcode overlay that can silently hide real linking errors.

Test Projects

As was mentioned in Article #1: all experiments in this article series use the following repository:

https://github.com/nikolay-dementiev/Library-linking_Static_Dynamic-Article

Let’s quick remind: it contains two setups:

1. Xcode Framework setup

  • Client app: Library-linking-Test-Xcode-Framework
  • Static framework: XcodeFrameworkStatic
  • Dynamic framework: XcodeFrameworkDynamic

2. Swift Package Manager setup

  • Client app: Library-linking-Test
  • Static package: SDK-SPM-Static
  • Dynamic package: SDK-SPM-Dynamic

Both SDKs expose the same API:

public struct MyMath {
    public static func add(_ a: Int, _ b: Int) -> Int {
        print("ADD from SDK")
        return a + b
    }
}

The only difference is:

  • how they are built
  • how they are linked

Which makes them perfect for binary-level comparison.


What We Expect (Hypothesis)

If dispatching really differs:

Variant Expectation
Static Symbols merged into app binary
Dynamic Separate Mach-O loaded by dyld
Debug Extra .debug.dylib wrapper
Release Direct embedding or linking

We will verify this with tools.


Setup our Test Project with SPM and Xcode Framework using Static / Dynamic Dispatching

SPM

With Swift Package Manager, we control how the library is linked by specifying the type of the product in Package.swift.

Static Dynamic

That is essentially all we need to configure.
SPM will automatically link the package to the main app according to this setting.

So with SPM:

  • type: .static → the library code is merged into the app binary
  • type: .dynamic → the library is built as a separate .dylib and loaded at runtime by dyld

The rest of the linking process (build phases, search paths, embedding) is handled automatically by Xcode and SPM.

Xcode Framework

Compared to SPM, using an Xcode Framework target requires more manual configuration.

1. Choose how the framework is built (static vs dynamic)

We define this using Mach-O Type in the framework target’s Build Settings:

Static Dynamic

This setting controls how the framework itself is produced:

  • Static → the framework is archived and later merged into the app binary
  • Dynamic → the framework is built as a separate Mach-O binary (.framework / .dylib)

2. Choose how the framework is embedded into the app

Next, we configure how the main app target links and embeds this framework:

The important options here are:

  • Embed & Sign
    This means:

    • the framework will be copied into the app bundle
    • it will be code-signed
    • it will be loaded at runtime by dyld

    This option is required for:

    • dynamic frameworks
    • any framework that must exist as a separate binary at runtime
  • Do Not Embed
    This means:

    • the framework will NOT be copied into the app bundle
    • its code is expected to already be part of the app binary

    This option is correct for:

    • static frameworks
    • frameworks whose code is merged into the app at link time

we will play with those setting a bit in upcoming Article 3 to reveal some edge cases

So for Xcode Frameworks, we must configure two independent things:

  1. how the framework is built (Mach-O Type)
  2. how the app embeds or does not embed it (Embed setting)

With SPM, both of these steps are abstracted away and handled automatically.

3. BTW: What Mach-O Type does the main app use?

The Mach-O Type for the main app target is always:

Executable

This means:

  • the app itself is built as a runnable Mach-O binary
  • all static libraries and static frameworks are merged into this executable
  • dynamic frameworks are loaded into this executable’s address space at runtime by dyld

In other words:

the app is the final container where all linking strategies (static and dynamic) come together.

This is why all earlier choices (static vs dynamic framework, embed vs not embed) ultimately affect:

  • what ends up inside the app executable
  • and what must be loaded separately at runtime

Inspecting what type of Linked Images we use in our main app (using the otool -L command)

Let’s build the main app in DEBUG mode using a dynamic framework, then run:

otool -L Library-linking-Test-Xcode-Framework.app/Library-linking-Test-Xcode-Framework

Expected output pattern:

@rpath/Library-linking-Test-Xcode-Framework.debug.dylib (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)

This shows that in DEBUG mode the app is indirectly linked through the Dynamic Linker by using the {OUR-App-Name}.debug.dylib file.

Now, let’s inspect the debug dylib itself:

otool -L Library-linking-Test-Xcode-Framework.app/Library-linking-Test-Xcode-Framework.debug.dylib
We should see something like (click to discover full response):
@rpath/XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic (compatibility version 1.0.0, current version 1.0.0)
..
/System/Library/Frameworks/UIKit.framework/UIKit (compatibility version 1.0.0, current version 9126.2.4)
..
/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
...
@rpath/Library-linking-Test-Xcode-Framework.debug.dylib (compatibility version 0.0.0, current version 0.0.0)
@rpath/XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic (compatibility version 1.0.0, current version 1.0.0)
/System/Library/Frameworks/Foundation.framework/Foundation (compatibility version 300.0.0, current version 4201.0.0)
/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)
/System/Library/Frameworks/DeveloperToolsSupport.framework/DeveloperToolsSupport (compatibility version 1.0.0, current version 23.0.4)
/System/Library/Frameworks/SwiftUI.framework/SwiftUI (compatibility version 1.0.0, current version 7.2.5)
/System/Library/Frameworks/UIKit.framework/UIKit (compatibility version 1.0.0, current version 9126.2.4)
/usr/lib/swift/libswiftCore.dylib (compatibility version 0.0.0, current version 0.0.0)
/usr/lib/swift/libswiftCoreFoundation.dylib (compatibility version 1.0.0, current version 120.100.0, weak)
/usr/lib/swift/libswiftCoreImage.dylib (compatibility version 1.0.0, current version 2.2.0, weak)
/usr/lib/swift/libswiftDarwin.dylib (compatibility version 1.0.0, current version 347.40.2, weak)
/usr/lib/swift/libswiftDispatch.dylib (compatibility version 1.0.0, current version 56.0.0, weak)
/usr/lib/swift/libswiftMetal.dylib (compatibility version 1.0.0, current version 370.64.2, weak)
/usr/lib/swift/libswiftOSLog.dylib (compatibility version 1.0.0, current version 10.0.0, weak)
/usr/lib/swift/libswiftObjectiveC.dylib (compatibility version 1.0.0, current version 951.1.0, weak)
/usr/lib/swift/libswiftQuartzCore.dylib (compatibility version 1.0.0, current version 5.0.0, weak)
/usr/lib/swift/libswiftSpatial.dylib (compatibility version 1.0.0, current version 1.0.0, weak)
/usr/lib/swift/libswiftUniformTypeIdentifiers.dylib (compatibility version 1.0.0, current version 879.2.4, weak)
/usr/lib/swift/libswiftXPC.dylib (compatibility version 1.0.0, current version 105.0.14, weak)
/usr/lib/swift/libswift_Concurrency.dylib (compatibility version 1.0.0, current version 0.0.0)
/usr/lib/swift/libswiftos.dylib (compatibility version 1.0.0, current version 1076.40.2, weak)
/usr/lib/swift/libswiftsimd.dylib (compatibility version 1.0.0, current version 23.0.0, weak)

So, what does this mean?

  • The app binary loads:
    • a debug dylib
  • The debug dylib loads:
    • the dynamic framework
  • The dynamic framework (our .debug.dylib) remains its own separate Mach-O image

And once again:

➡️ Dynamic framework = a separate image loaded by dyld


Xcode dynamic linking in DEBUG mode

ℹ️
Did you know that Xcode uses a form of dynamic linking in DEBUG mode even for Static Frameworks?

Let’s uncover an interesting Xcode behavior for the main app in a DEBUG build:

Remove the Dynamic Framework embedding.

Set:

  • Embed & SignDo Not Embed

Rebuild and run from Xcode.

Result:

✔️ The app works.

Stop debugging and run the freshly built app directly in the Simulator (not from Xcode), or copy the .app to another Simulator.

🚫

Result:

💥 The app crashes.

Console log:

...
Termination Reason:  Namespace DYLD, Code 1, Library missing; Library not loaded: @rpath/XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic
...

Why does this happen?

Because Xcode injects runtime paths and loader state when running in DEBUG.

We will analyze this behavior in detail in Article 3 (Xcode runtime tricks).


Static Framework Case

Now remove the dynamic framework and link only the static one:

Do not forget to:

comment out unused code (import) and clear the Xcode cache

Then run:

otool -L Library-linking-Test-Xcode-Framework.app/Library-linking-Test-Xcode-Framework.debug.dylib
Expected (click to discover whole response)
Library-linking-Test-Xcode-Framework.debug.dylib:
	@rpath/Library-linking-Test-Xcode-Framework.debug.dylib (compatibility version 0.0.0, current version 0.0.0)
	/System/Library/Frameworks/Foundation.framework/Foundation (compatibility version 300.0.0, current version 4201.0.0)
	/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)
/System/Library/Frameworks/DeveloperToolsSupport.framework/DeveloperToolsSupport (compatibility version 1.0.0, current version 23.0.4)
	/System/Library/Frameworks/SwiftUI.framework/SwiftUI (compatibility version 1.0.0, current version 7.2.5)
	/System/Library/Frameworks/UIKit.framework/UIKit (compatibility version 1.0.0, current version 9126.2.4)
	/usr/lib/swift/libswiftCore.dylib (compatibility version 0.0.0, current version 0.0.0)
	/usr/lib/swift/libswiftCoreFoundation.dylib (compatibility version 1.0.0, current version 120.100.0, weak)
	/usr/lib/swift/libswiftCoreImage.dylib (compatibility version 1.0.0, current version 2.2.0, weak)
	/usr/lib/swift/libswiftDarwin.dylib (compatibility version 1.0.0, current version 347.40.2, weak)
	/usr/lib/swift/libswiftDispatch.dylib (compatibility version 1.0.0, current version 56.0.0, weak)
	/usr/lib/swift/libswiftMetal.dylib (compatibility version 1.0.0, current version 370.64.2, weak)
	/usr/lib/swift/libswiftOSLog.dylib (compatibility version 1.0.0, current version 10.0.0, weak)
	/usr/lib/swift/libswiftObjectiveC.dylib (compatibility version 1.0.0, current version 951.1.0, weak)
	/usr/lib/swift/libswiftQuartzCore.dylib (compatibility version 1.0.0, current version 5.0.0, weak)
	/usr/lib/swift/libswiftSpatial.dylib (compatibility version 1.0.0, current version 1.0.0, weak)
	/usr/lib/swift/libswiftUniformTypeIdentifiers.dylib (compatibility version 1.0.0, current version 879.2.4, weak)
	/usr/lib/swift/libswiftXPC.dylib (compatibility version 1.0.0, current version 105.0.14, weak)
	/usr/lib/swift/libswift_Concurrency.dylib (compatibility version 1.0.0, current version 0.0.0)
	/usr/lib/swift/libswiftos.dylib (compatibility version 1.0.0, current version 1076.40.2, weak)
	/usr/lib/swift/libswiftsimd.dylib (compatibility version 1.0.0, current version 23.0.0, weak)

There is no reference to our Dynamic SDK at all.

Now try to search for its symbols:

nm Library-linking-Test-Xcode-Framework | grep MyMath
Expected [pseudo code] (click to discover real command response)
_t_$s...MyMath...add
0000000000003fa8 T _$s20XcodeFrameworkStatic06MyMathC0V3addyS2i_SitFZ
00000000000040b0 T _$s20XcodeFrameworkStatic06MyMathC0VACycfC
0000000000005084 s _$s20XcodeFrameworkStatic06MyMathC0VMF
00000000000043d4 T _$s20XcodeFrameworkStatic06MyMathC0VMa
00000000000084c0 s _$s20XcodeFrameworkStatic06MyMathC0VMf
0000000000005048 S _$s20XcodeFrameworkStatic06MyMathC0VMn
00000000000084d0 S _$s20XcodeFrameworkStatic06MyMathC0VN
0000000000008468 s _$s20XcodeFrameworkStatic06MyMathC0VWV
00000000000040bc t _$s20XcodeFrameworkStatic06MyMathC0Vwet
000000000000420c t _$s20XcodeFrameworkStatic06MyMathC0Vwst
0000000000004d60 s _symbolic _____ 20XcodeFrameworkStatic06MyMathC0V

This means:

  1. ➡️ The static SDK’s code is physically merged into the app’s Mach-O
  2. No dyld load
  3. No runtime lookup

  1. Just direct jumps

Visual Model of Dispatching

Static Dispatch (Static framework / static SPM target)

Dynamic Dispatch (Dynamic framework / .dylib)


This difference explains:

Framework Static Dynamic
Where is code? merged into the main Mach-O extra Mach-O image + symbol resolution by dyld
Where is image?
(approved with the otool)
no separate image visible as a separate image

Swift Package Manager Comparison

Let’s build our main app with:

  • SDK-SPM-Static
  • and SDK-SPM-Dynamic

So, our Test App folder structure looks like:

<Our App Folder>
├── ClientApp
│   └── Library-linking-Test
│       ├── Library-linking-Test
│       └── Library-linking-Test.xcodeproj
└── Framework
    └── SDK-SPM
        ├── SDK-SPM-Dynamic
        └── SDK-SPM-Static

and it’s XCode settings:

Lets inspect linking Static and Dynamic Frameworks with:

otool -L Library-linking-Test.debug.dylib | grep Dynamic

and

otool -L Library-linking-Test.debug.dylib | grep Static

Result in accordingly:

Dynamic:

@rpath/SDK-SPM-Dynamic.framework/SDK-SPM-Dynamic (compatibility version 0.0.0, current version 0.0.0)

Static:

⚠️

No SDK reference.

Meaning:

➡️ SPM follows the same Mach-O rules
➡️ Dispatching is not an SPM feature
➡️ It is just a linker + loader behavior


CocoaPods (Conceptual Only)

ℹ️
CocoaPods does not define how dispatching works.
It only controls how a dependency is packaged (static vs dynamic).

Actual dispatching still follows the same Mach-O + dyld rules as:

  • Xcode frameworks
  • Swift Package Manager products

Historically, CocoaPods used this switch:

use_frameworks!

Which meant that Pods were built as dynamic frameworks Without it Pods were built as static libraries

Modern CocoaPods behavior

Today, CocoaPods (no use_frameworks!) is static by default

If you add to your PODFile:

use_frameworks!

Then the dependencies are built as dynamic frameworks

If you swap to:

use_frameworks! :linkage => :static

-> Dependencies will be built as static frameworks again

So CocoaPods can produce static\dynamic frameworks depending on configuration.

Visual model: what CocoaPods really controls

Runtime model (after CocoaPods is done)

Important limitation

All of this applies only to pods built from source.

If a pod is distributed as a precompiled binary:

  • CocoaPods cannot change its type
  • it will remain whatever it was shipped as:
    • static library, or
    • static framework
Most precompiled pods are static libraries / frameworks

Key takeaway for CocoaPods:

it does not:

  • invent dispatching
  • change symbol resolution
  • change how dyld works

It only decides:

  • how the dependency is built and linked

And once built, the runtime behavior is governed purely by:

  • Mach-O format
  • linker decisions
  • and dyld loader logic

We do not deep-dive CocoaPods internals here to keep focus on dispatching itself.

Read more »


Observing Lazy Binding

In Article 1 we built the mental model of lazy binding — the idea that a dynamic symbol is not resolved at launch, but on first use. The stub jumps to the GOT, dyld patches the real address, and every subsequent call goes directly.

Now let’s stop talking about it and actually see it inside our binaries.

What we’re looking for

Modern Apple binaries do not use __la_symbol_ptr (the classic lazy pointer table) anymore.
Instead, lazy binding lives in three places:

Piece Location Role
Stub __TEXT,__stubs tiny jump point called by your code
GOT entry __DATA_CONST,__got pointer patched by dyld at runtime
dyld resolver inside dyld itself finds the real address and patches the GOT

So the question becomes:

“Can I actually find __got in my dynamic framework — and confirm it’s absent in the static one?”

Yes. Let’s do it!:

Inspect the GOT in a dynamic framework

Our app bundle looks like this:

Library-linking-Test-Xcode-Framework.app
├── Frameworks
│   └── XcodeFrameworkDynamic.framework
│       ...
│       └─── XcodeFrameworkDynamic ← dynamic Mach-O
...
├── Library-linking-Test-Xcode-Framework
└── Library-linking-Test-Xcode-Framework.debug.dylib ← thin launcher (Debug)
...

Let’s look inside the dynamic framework:

otool -l XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic | grep -A4 __got

Output:

  sectname __got
   segname __DATA_CONST
      addr 0x0000000000004000
      size 0x0000000000000038
    offset 16384

You’ll see a __DATA_CONST,__got section — so the GOT lives here.

Now dump its contents:

otool -v -s __DATA_CONST __got XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic

Output:

XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic:
Contents of (__DATA_CONST,__got) section
Unknown section type (0x6)
0000000000004000	00000000 80100000 00000001 80100000
0000000000004010	00000002 80100000 00000003 80100000
0000000000004020	00000004 80100000 00000005 80100000
0000000000004030	00000006 80100000

Those values are placeholder addresses — not real function pointers yet.
dyld will patch them with the real function addresses on the first call to each symbol.
After that — the stub reads the now-patched GOT entry and jumps directly to the functionno dyld involved anymore.

Find the stubs that use it

Now let’s look at the disassembly of our debug.dylib and grep for stubs:

otool -tvV Library-linking-Test-Xcode-Framework.debug.dylib | grep stub

A fragment of the output:

...
00000000000012b8	bl	0x4b5c ; symbol stub for: _$s7SwiftUI12SceneBuilderV10buildBlockyxxAA0C0RzlFZ
00000000000015dc	bl	0x4b2c ; symbol stub for: _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZ
0000000000001604	bl	0x4c40 ; symbol stub for: _$ss26DefaultStringInterpolationV06appendC0yyxs06CustomB11ConvertibleRzlF
...

Notice this line:

00000000000015dc	bl	0x4b2c ; symbol stub for: _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZ

That mangled name — _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZ — is our MyMath.add(_:_:).

The call goes through a stub at address 0x4b2c.
That stub loads a pointer from the GOT.
On first call — dyld resolves the symbol, patches the GOT entry with the real function address.
On every next call — the stub reads the already-patched GOT entry and jumps directly to the function.

This is lazy binding. Not a concept anymore — this is your binary.

Confirm the static framework has none of this

Now let’s check the static side. Run nm against the app binary linked with the static framework:

nm Library-linking-Test-Xcode-Framework.debug.dylib | grep MyMath
You'll see something like (click to expand):
0000000000004170 T _$s20XcodeFrameworkStatic06MyMathC0V3addyS2i_SitFZ
0000000000004278 T _$s20XcodeFrameworkStatic06MyMathC0VACycfC
0000000000005254 s _$s20XcodeFrameworkStatic06MyMathC0VMF
000000000000459c T _$s20XcodeFrameworkStatic06MyMathC0VMa
00000000000084c8 s _$s20XcodeFrameworkStatic06MyMathC0VMf
0000000000005218 S _$s20XcodeFrameworkStatic06MyMathC0VMn
00000000000084d8 S _$s20XcodeFrameworkStatic06MyMathC0VN
0000000000008470 s _$s20XcodeFrameworkStatic06MyMathC0VWV
0000000000004284 t _$s20XcodeFrameworkStatic06MyMathC0Vwet
00000000000043d4 t _$s20XcodeFrameworkStatic06MyMathC0Vwst
                 U _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZ
0000000000004f34 s _symbolic _____ 20XcodeFrameworkStatic06MyMathC0V

The T means: defined in the text segment — the symbol lives directly inside the app binary. No stub. No GOT entry. No dyld patching. Just a direct call at link time.

You can confirm this directly by counting __got occurrences in both binaries:

otool -l XcodeFrameworkStatic.framework/XcodeFrameworkStatic | grep -c __got

AND


otool -l XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic | grep -c __got
Binary Output Meaning
XcodeFrameworkStatic 0 no GOT — no dyld patching needed
XcodeFrameworkDynamic 1 __DATA_CONST,__got section present — dyld patches it at runtime

What this looks like visually

Lazy binding: dynamic vs static

Key takeaway

Lazy binding is not a CocoaPods feature. Not an SPM feature. Not an Xcode feature.
It is a Mach-O + linker + dyld mechanism — and now you’ve seen it with your own eyes.

Dynamic framework Static framework
GOT entry (__DATA_CONST,__got)
Symbol stub (__TEXT,__stubs)
dyld runtime patching
Direct call at link time

Runtime confirmation: lldb image list

One more way to confirm: pause the app in lldb and run:

(lldb) image list

Every Mach-O image dyld has loaded into the process is listed. In this project — linking both XcodeFrameworkStatic and XcodeFrameworkDynamic — there are 734 entries total. The meaningful ones:

[  0] C616C5D5-... 0x000000010206c000  .../Library-linking-Test-Xcode-Framework.app/Library-linking-Test-Xcode-Framework
      ...
[ 22] 14C85662-... 0x0000000102120000  .../Library-linking-Test-Xcode-Framework.app/Library-linking-Test-Xcode-Framework.debug.dylib
      ...
[ 87] 23F6AF64-... 0x0000000102110000  .../XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic
  • [0] — the app binary. All static framework code is inside it.
  • [22]*.debug.dylib — a DEBUG-only image Xcode injects for SwiftUI Previews / fast hot-reload. Not present in Release builds.
  • [87]XcodeFrameworkDynamic — our dynamic framework, mapped as its own dyld image.

And XcodeFrameworkStatic? It does not appear in image list as a standalone image. You can confirm exactly where its code lives using image lookup:

(lldb) image lookup -rn XcodeFrameworkStatic

DEBUG build — symbols resolved through the .debug.dylib overlay (image [22]):

4 matches found in .../Library-linking-Test-Xcode-Framework.app/Library-linking-Test-Xcode-Framework.debug.dylib:
  Summary: Library-linking-Test-Xcode-Framework.debug.dylib`static XcodeFrameworkStatic.MyMathStatic.add(Swift.Int, Swift.Int) -> Swift.Int
  Summary: Library-linking-Test-Xcode-Framework.debug.dylib`type metadata accessor for XcodeFrameworkStatic.MyMathStatic
  ...

Release build — symbols resolved directly inside the main binary (image [0]):

4 matches found in .../Library-linking-Test-Xcode-Framework.app/Library-linking-Test-Xcode-Framework:
  Address: Library-linking-Test-Xcode-Framework[0x0000000100001e40] (__TEXT.__text + 3360)
  Summary: Library-linking-Test-Xcode-Framework`static XcodeFrameworkStatic.MyMathStatic.add(Swift.Int, Swift.Int) -> Swift.Int
  Address: Library-linking-Test-Xcode-Framework[0x0000000100001ef0] (__TEXT.__text + 3536)
  Summary: Library-linking-Test-Xcode-Framework`getEnumTagSinglePayload value witness for XcodeFrameworkStatic.MyMathStatic
  ...

In both cases the static framework’s code lives inside another image, never as a standalone dyld entry. In Release that image is [0] — the app binary itself.


Why This Matters (SwiftUI Context)

So far we’ve been looking at binary internals — GOT entries, stubs, mangled symbols.
But does any of this actually affect a real SwiftUI app?

Yes. And here’s where:

App startup time

Every dynamic framework your app links against must be loaded and resolved by dyld at launch — before your first SwiftUI view ever renders.

This is not free. On older devices or with many dynamic dependencies, this cost is measurable.

You can observe it yourself in Xcode’s launch time report in Instruments → App Launch.

Static frameworks skip this entirely — their code is already inside the app binary.
No dyld loading. No symbol resolution at launch. The first frame arrives faster.

This is why Apple’s own guidance recommends keeping the number of dynamic frameworks low — especially for app targets where launch time matters.
Read more »

SwiftUI Previews and .debug.dylib

If you’ve ever wondered why SwiftUI Previews feel “magical” — here’s part of the answer.

Previews need to reload your code without restarting the simulator.
To do that, Xcode builds your app code into a .debug.dylib — a separate dynamic library that can be rebuilt and reloaded independently from the app binary.

This is why in DEBUG builds you see:

otool -L Library-linking-Test-Xcode-Framework.app/Library-linking-Test-Xcode-Framework

Output:

@rpath/Library-linking-Test-Xcode-Framework.debug.dylib (compatibility version 0.0.0, current version 0.0.0)
...

Your app binary becomes a thin launcher.
The actual code — including your static frameworks — lives inside the .debug.dylib.

This means:

  • Xcode can rebuild only the .debug.dylib on each change
  • Previews can reload the new dylib without a full relaunch
  • Your static frameworks are temporarily treated as dynamic in DEBUG

We will go deep on .debug.dylib in Article 3 — including why this can hide real linking errors that only surface in RELEASE.

Binary size

Static frameworks merge their code directly into the app binary.
Every symbol, every function — copied in at link time by the linker.

Dynamic frameworks keep their code in a separate Mach-O. The app binary stays smaller — but the total .app bundle size includes the framework bundle.

The size command makes this structural difference visible:

size XcodeFrameworkStatic.framework/XcodeFrameworkStatic
size XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic

Staticsize lists each .o file separately, because a static framework is an archive of object files:

__TEXT  __DATA  __OBJC  others  dec     hex
    96       0       0    1503  1599    63f     XcodeFrameworkStatic.framework/XcodeFrameworkStatic(XcodeFrameworkStatic_vers.o)
  1396     176       0    5488  7060   1b94     XcodeFrameworkStatic.framework/XcodeFrameworkStatic(XcodeFrameworkStatic.o)

Dynamic — a single linked Mach-O, one entry:

__TEXT  __DATA  __OBJC  others  dec     hex
 16384       0       0   49152  65536  10000     XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic

Two things to notice:

  • Static format: individual .o files are visible — the framework is an archive, not a standalone binary. Total __TEXT is ~1.5 KB of actual code.
  • Dynamic __TEXT = 16384: exactly one VM page (16 KB). A dylib must be page-aligned, so even a minimal dynamic framework pays at least one page of overhead per segment, regardless of actual code size.

So the tradeoff is not “small vs large” — it’s where the code lives:

Static Dynamic
App binary size larger smaller
Total .app bundle same code, different location framework copied into Frameworks/
Shared between processes ✅ (system frameworks only)

Note: on iOS, dynamic frameworks are not shared between apps — unlike macOS. Each app carries its own copy inside the .ipa. So the “shared memory” benefit applies only to Apple’s system frameworks (UIKit, SwiftUI, Foundation etc.).

Modular updates

As was briefly described in Article 1, on macOS (and server-side Swift), dynamic frameworks bring a real advantage:
a framework can be updated independently from the app binary.

This means:

  • ship a bug fix in a framework — without recompiling the app
  • multiple apps share the same dylib in memory — loaded once by dyld

This is exactly how Apple’s own system frameworks work:
UIKit, SwiftUI, Foundation — one copy in memory, shared across all processes.

On iOS however, this advantage largely disappears:

  • App Store apps are sandboxed
  • each app carries its own private copy of every dynamic framework inside its .ipa
  • no sharing between apps
  • updating a framework still requires resubmitting the app

So the “modular updates” argument is:

Platform Dynamic framework benefit
macOS / server ✅ real — update framework without rebuilding app
iOS ❌ limited — full app resubmission required anyway
System frameworks (Apple) ✅ always — shared across all processes

Key takeaway

Framework linking type affects your app in four concrete ways:

  1. Launch timedynamic frameworks add dyld resolution cost before first render
  2. PreviewsXcode wraps everything in a .debug.dylib to enable fast reload
  3. Binary layoutstatic merges into app binary, dynamic lives in Frameworks/
  4. Modular updates — real benefit on macOS, limited on iOS

These are not theoretical concerns.
You’ve already seen the stubs, the GOT entries, the .debug.dylib — with your own terminal commands.


What We Learned

We did not just read about static vs dynamic frameworks.
We opened real binaries, ran real commands, and saw real evidence.

Here is what the terminal told us:

What we observed What it means
MyMath.add symbol inside app Mach-O (nmT) static framework code merged at link time
XcodeFrameworkDynamic appears in otool -L output dynamic framework loaded by dyld at runtime
__DATA_CONST,__got section exists in dynamic framework GOT entries — patched by dyld on first call
bl 0x... ; symbol stub for: _$s21XcodeFrameworkDynamic... every dynamic call goes through a stub
static framework binary has no __got, no stubs (otool -l | grep -c __got0) direct calls only — no dyld involvement
SPM follows the same Mach-O rules as Xcode frameworks dispatching is a linker + loader behavior, not a build tool feature
CocoaPods use_frameworks! controls how a pod is packaged, not how dyld dispatches it CocoaPods is a builder — dyld rules are unchanged
.debug.dylib appears in DEBUG build otool -L output Xcode wraps your app for fast reload and SwiftUI Previews

The dispatch rules never change.
Only the containers do.


What Comes Next (Article 3)

In this article, we moved from theory to binary-level evidence.

We proved — with real terminal commands — that:

  • static frameworks are merged into the app Mach-O
  • dynamic frameworks are loaded by dyld as separate images
  • lazy binding lives in __DATA_CONST,__got — not in __la_symbol_ptr anymore
  • SPM and Xcode follow identical Mach-O + dyld rules
  • DEBUG builds wrap everything in a .debug.dylib

But one question remains:

“Why does the app still work in DEBUG when I remove Embed & Sign for a dynamic framework —
but crashes when I run it outside Xcode?”

In Article 3, we will explain exactly that:

  • what the .debug.dylib really is — and why Xcode creates it
  • what @rpath means and how Xcode injects runtime loader paths in DEBUG
  • why Embed & Sign seems to be “ignored” in DEBUG — but is critical in RELEASE
  • why DEBUG builds are not representative of real runtime behavior
  • and why this matters for CI, TestFlight, and App Store builds

Article 3 = the DEBUG vs RELEASE mystery. Solved.

Further Reading


Next: Article 3 — Why Debug Lies to You: dyld, rpaths, and the Debug Dylib

Last updated on