Skip to main content

Dynamic Linking in Xcode

How dynamic linking works in Xcode — what dylibs and frameworks are, how dyld resolves symbols at launch, and the trade-offs compared to static linking.

What is Dynamic Linking?

Dynamic linking defers symbol resolution from build time to launch time. Instead of copying library code into your app binary, the linker records a dependency: “this app needs UIKit.framework, and it calls these symbols from it.” When the OS launches your app, the dynamic linker (dyld) loads the required libraries into the process and patches up every symbol reference before main() runs.

The Mach-O executable itself is smaller with dynamic linking, but the total app bundle is larger — the full .framework must be embedded inside MyApp.app/Frameworks/, and it ships to the App Store in its entirety. Dead code stripping does not cross the dylib boundary, so unused code inside a dynamic framework is never removed. A real-world conversion from dynamic to static linking typically produces a measurable bundle size reduction.


Key Terms

TermWhat It Is
dylibA dynamic library — a Mach-O file with type MH_DYLIB. Contains compiled code and a symbol table.
FrameworkA bundle (directory) that wraps a dylib with headers, resources, and metadata. The dylib lives at MyLib.framework/MyLib.
dyldThe Apple dynamic linker. Loaded by the kernel before your app starts; it resolves, loads, and initialises all linked dylibs.
LC_LOAD_DYLIBA load command in your Mach-O binary that names a dependency. dyld reads these to know what to load.
RPATHA search path embedded in the binary (@rpath) that tells dyld where to look for dylibs at runtime.
Shared cacheA pre-linked file on-device that contains all Apple system frameworks. dyld maps it into every process — no per-process disk load.

How dyld Works at Launch

  1. The kernel maps your app binary into memory and hands control to dyld.
  2. dyld reads every LC_LOAD_DYLIB command in your binary to build a dependency list.
  3. For each dependency it finds the dylib on disk (using RPATH and a fixed search order) and maps it into the process address space.
  4. dyld resolves symbols — patches the actual memory addresses of functions into the call sites that reference them.
  5. Initialisers (C++ static constructors, ObjC +load methods, Swift top-level code) run in dependency order.
  6. Control passes to your @main / UIApplicationMain.

Steps 1-5 happen before any of your code runs. This is why a binary with many embedded dylibs has a slower cold launch — every additional dylib adds disk I/O, mapping, and symbol-binding work.

App binary (Mach-O)
  LC_LOAD_DYLIB /System/Library/Frameworks/UIKit.framework/UIKit
  LC_LOAD_DYLIB @rpath/MySDK.framework/MySDK
  LC_LOAD_DYLIB @rpath/Alamofire.framework/Alamofire

dyld at launch:
  1. Map UIKit from shared cache (free — already in memory system-wide)
  2. Find MySDK.framework via @rpath → load from app bundle
  3. Find Alamofire.framework via @rpath → load from app bundle
  4. Resolve all symbol references
  5. Run initialisers
  6. Call main()

System Frameworks vs. Your Own Dylibs

This distinction matters a lot for performance.

System frameworks (UIKit, Foundation, SwiftUI, etc.) live in the dyld shared cache — a single pre-linked file that ships with the OS. Every app on the device shares the same in-memory mapping. Linking against UIKit costs nothing at launch because it is already loaded.

Your own embedded dylibs (your app’s frameworks, third-party SDKs shipped as dynamic frameworks) are not in the shared cache and are not shared across processes. dyld has to find them on disk, map them, and bind their symbols individually at every cold launch. Each embedded dylib adds roughly 1-5 ms to launch time on modern hardware, but it compounds quickly. Critically, all embedded dynamic frameworks load unconditionally at app startup — there is no on-demand or lazy loading. A framework you link dynamically but only use in one rarely-visited screen still pays its launch-time cost on every app open.

Apple’s WWDC guidance: keep embedded dylibs to six or fewer for launch-time-sensitive apps.


Framework Bundle Structure

MyLib.framework/
  MyLib                  ← the actual Mach-O dylib (MH_DYLIB)
  Headers/               ← public headers (ObjC/C)
  Modules/
    module.modulemap     ← Clang module definition
    MyLib.swiftmodule/   ← Swift module interface files
  Info.plist
  _CodeSignature/        ← code signature for the framework slice

When you embed a framework in an iOS app, Xcode copies the entire bundle into MyApp.app/Frameworks/. The app binary’s RPATH is set to @executable_path/Frameworks so dyld knows to look there.


Build Settings That Matter

SettingKeyWhat It Does
Mach-O TypeMACH_O_TYPESet to mh_dylib to produce a dynamic framework
Runpath Search PathsLD_RUNPATH_SEARCH_PATHSAdds @rpath entries to the binary — usually @executable_path/Frameworks
Embed Frameworks(Build Phases → Embed Frameworks)Copies the .framework into the app bundle and code-signs it
Build Library for DistributionBUILD_LIBRARY_FOR_DISTRIBUTIONEmits .swiftinterface for stable ABI across compiler versions
Strip Debug SymbolsSTRIP_INSTALLED_PRODUCTStrips DWARF from the embedded framework in release builds

These are two separate things and both must be set for an embedded framework to work.

ActionWhat It DoesWhere in Xcode
LinkAdds LC_LOAD_DYLIB to your binary; gives you the public API at compile timeBuild Phases → Link Binary With Libraries
EmbedCopies the .framework into MyApp.app/Frameworks/ so dyld can find it at runtimeBuild Phases → Embed Frameworks

If you link but don’t embed, the app crashes at launch: dyld: Library not loaded.
If you embed but don’t link, the code won’t compile: No such module 'MyLib'.


@rpath, @executable_path, @loader_path

These are special tokens dyld expands when searching for dylibs.

TokenExpands To
@executable_pathThe directory containing the app binary (MyApp.app/)
@loader_pathThe directory of the binary that triggered the load (useful inside frameworks that load other frameworks)
@rpathEach path in the binary’s LC_RPATH load commands, searched in order

A typical iOS app binary has:

LC_RPATH  @executable_path/Frameworks

So @rpath/MyLib.framework/MyLib resolves to MyApp.app/Frameworks/MyLib.framework/MyLib.


Creating a Dynamic Framework Target

Xcode → New Target → Framework & Library → Framework

The default Mach-O Type is mh_dylib. Build it, then in the consuming app target:

  1. Link: Build Phases → Link Binary With Libraries → add MyLib.framework
  2. Embed: Build Phases → Embed Frameworks → add MyLib.framework, set “Code Sign on Copy” to on

For distribution as an XCFramework:

xcodebuild archive \
  -scheme MyLib \
  -destination "generic/platform=iOS" \
  -archivePath ./archives/ios \
  SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES

xcodebuild archive \
  -scheme MyLib \
  -destination "generic/platform=iOS Simulator" \
  -archivePath ./archives/sim \
  SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES

xcodebuild -create-xcframework \
  -archive ./archives/ios.xcarchive -framework MyLib.framework \
  -archive ./archives/sim.xcarchive -framework MyLib.framework \
  -output MyLib.xcframework

Weak Linking

Weak linking lets you reference a symbol that might not exist at runtime — useful for calling APIs only available on newer OS versions.

// Swift handles this automatically with @available
if #available(iOS 17, *) {
    // Safe to call iOS 17 API
}

Under the hood, Xcode emits a weak reference (LC_LOAD_WEAK_DYLIB or a weak symbol binding). If the symbol is absent at runtime, dyld sets the pointer to nil rather than crashing.

For ObjC:

// Link the framework as "Optional" in Build Phases
// Then guard with a nil check
if (NSClassFromString(@"UISheetPresentationController")) {
    // Available
}

Inspecting Dynamic Linking

# List all dynamic dependencies of a binary
otool -L MyApp.app/MyApp

# Show load commands (including all LC_LOAD_DYLIB and LC_RPATH)
otool -l MyApp.app/MyApp | grep -A4 "LC_LOAD_DYLIB\|LC_RPATH"

# Show symbols exported by a dylib
nm -gU MyLib.framework/MyLib

# Show undefined symbols in a binary (what it needs from dylibs)
nm -u MyApp.app/MyApp

# Print dyld's full resolution log at launch (simulator only)
DYLD_PRINT_LIBRARIES=1 ./MyApp

# Print every symbol binding dyld performs
DYLD_PRINT_BINDINGS=1 ./MyApp

Common Crash: dyld: Library not loaded

dyld: Library not loaded: @rpath/MyLib.framework/MyLib
  Referenced from: /private/var/containers/.../MyApp.app/MyApp
  Reason: image not found

Checklist:

  1. Is the framework in Build Phases → Embed Frameworks? (most common cause)
  2. Is “Code Sign on Copy” enabled for the embedded framework?
  3. Does the framework’s supported platform match the build destination (device vs. simulator slice)?
  4. Is the RPATH in the app binary correct? (otool -l MyApp | grep RPATH)

Trade-offs vs. Static Linking

Dynamic LinkingStatic Linking
Launch timeSlower — dyld maps and binds every embedded dylib before main() runsFaster — all symbols resolved at build time, nothing to load at launch
Mach-O executable sizeSmaller — library code lives in a separate fileLarger — all library code merged into one binary
Total bundle / IPA sizeLarger — full dylib embedded and ships in its entirety, no dead stripping across the boundarySmaller — linker dead-strips unused code from the whole binary
Memory (your frameworks)Each process maps its own copy — no sharing between the host app and extensionsSame — each process has its own copy in the merged binary
Memory (system frameworks)Shared via dyld shared cache — free, already in memory system-wideN/A — you cannot statically link system frameworks
Dead strippingNot applied inside the dylibApplied across the entire binary, including all static libraries
Independent updatesOnly system frameworks update independently with OS updates — your embedded dylibs cannot update without an app rebuild and resubmissionLibrary updates always require a rebuild and resubmission
Code sharing across targetsEfficient — host app and extensions can load one shared embedded frameworkEach target gets its own copy of the static library’s code in its binary

The practical rule for iOS: start with static linking for all your own code and third-party libraries. Dynamic frameworks make sense in two specific cases: you are distributing an SDK that developers link into their own apps, or your app target and an app extension share a substantial amount of code (since extensions are separate processes, a shared embedded framework avoids duplicating that code in two binaries).


Quick Reference

TaskCommand / Setting
Produce a dynamic frameworkMACH_O_TYPE = mh_dylib
Add RPATH to a binaryLD_RUNPATH_SEARCH_PATHS = @executable_path/Frameworks
List dynamic dependenciesotool -L MyApp.app/MyApp
Show load commandsotool -l MyApp.app/MyApp
Show exported symbolsnm -gU MyLib.framework/MyLib
Stable Swift ABIBUILD_LIBRARY_FOR_DISTRIBUTION = YES
Debug dyld resolutionDYLD_PRINT_LIBRARIES=1 (simulator only)
Weak-link an OS frameworkSet framework to “Optional” in Link Binary With Libraries