On Android, app launch is dominated by Zygote's fork() — the system preloads the framework into a warm process and clones it for each app (Android series, Post 2). iOS has no Zygote. There's no warm parent process waiting to be forked. Every app process starts from scratch.
And yet, launching an app on iOS feels fast — often faster than on Android. This isn't magic. iOS uses different optimisations: an aggressively pre-linked shared library cache, a fast dynamic linker, hardware-assisted memory mapping, and a launch process designed around the specific constraints of Apple's hardware and software stack.
This post traces the complete launch path from the user's tap to the first Flutter frame.
The launch sequence
When the user taps your app icon, the sequence involves three system components before your code runs a single instruction:
- SpringBoard (the iOS home screen / launcher) detects the tap and sends a launch request to launchd (the system's process manager, PID 1 — equivalent to Android's
init+ActivityManagerService).
- launchd creates the new process using
posix_spawn()— a system call that creates a new process and loads an executable into it in one operation. Unlike Android'sfork()+exec()pattern,posix_spawn()doesn't clone the parent's memory. The new process starts with a fresh address space.
- The kernel creates the Mach task and BSD process structures: a new virtual address space, a PID, the app's UID (determined by the app's bundle and sandbox profile), and the initial thread.
- The kernel loads the app's main executable (your app binary from the .app bundle) into the new address space and transfers control to the dynamic linker:
dyld.
dyld: the dynamic linker
dyld (dynamic linker/loader) is the first user-space code that runs in your process. Its job: load all the shared libraries your app depends on, resolve symbols, run initialisers, and then call main().
On iOS, dyld is highly optimised because it runs on every app launch:
The dyld shared cache
Every system framework — UIKit, Foundation, CoreFoundation, CoreGraphics, Metal, Security, hundreds of others — is bundled into a single pre-linked file called the dyld shared cache. This file is created at OS install time and contains every system library, already linked together with all inter-library symbol references resolved.
The shared cache is memory-mapped into every process at the same address. This means:
- No per-process linking for system libraries. When your app uses UIKit,
dylddoesn't need to find UIKit, load it, and resolve its symbols. It's already in the shared cache, mapped at a known address, with all symbols resolved. This saves hundreds of milliseconds compared to linking each framework individually.
- Shared physical pages. Like Android's Zygote-shared framework code, the shared cache's code pages are shared across all processes. UIKit's code is loaded once in physical memory and mapped into every app's address space. This is the iOS equivalent of Android's copy-on-write sharing.
- Code signing is pre-verified. The shared cache is signed as a unit. Individual framework pages don't need per-page signature verification at load time (they're verified at cache creation time).
The shared cache is typically 1-2GB in size and contains ~500 system frameworks. Without it, loading these frameworks individually on each app launch would take seconds.
What dyld does for your Flutter app
Your Flutter app's binary links against system frameworks (UIKit, Foundation) and the Flutter engine framework (Flutter.framework / App.framework). Here's dyld's work:
- Map the shared cache (if not already mapped — it usually is, since the cache is shared across processes). This gives the process access to all system frameworks instantly.
- Load the app binary from
/private/var/containers/Bundle/Application/<UUID>/YourApp.app/YourApp. This is a small binary — it contains themain()function, theAppDelegate, and links to the Flutter frameworks.
- Load Flutter.framework — the Flutter engine, equivalent to Android's
libflutter.so. This is an embedded framework inside your .app bundle.dyldmaps it into the address space and resolves its symbols.
- Load App.framework — your compiled Dart code, equivalent to Android's
libapp.so. Contains AOT-compiled Dart code and the Dart heap snapshot.
- Run initialisers —
+loadmethods in Objective-C classes, C++ static constructors,__attribute__((constructor))functions. Each loaded framework may have initialisers. System frameworks' initialisers are minimal (they were already run during shared cache creation), but plugin frameworks may have them.
- Call `main()` — your app's C entry point.
Measuring pre-main time
The time from process creation to main() is called pre-main time. You can measure it by setting the DYLD_PRINT_STATISTICS environment variable in Xcode:
Total pre-main time: 320.45 milliseconds (100.0%)
dylib loading time: 180.23 milliseconds (56.2%)
rebase/binding time: 12.45 milliseconds (3.8%)
ObjC setup time: 8.67 milliseconds (2.7%)
initializer time: 119.10 milliseconds (37.1%)For a Flutter app, dylib loading time is dominated by loading Flutter.framework (~8-10MB of native code). initializer time includes any Objective-C +load methods from plugins and system frameworks.
Pre-main time for a Flutter app on a modern iPhone is typically 200-400ms — similar in magnitude to the Android equivalent, but achieved through different mechanisms. Android's Zygote fork is ~5ms but then Flutter-specific loading takes ~200-300ms. iOS has no Zygote benefit but dyld + shared cache is fast enough that total pre-main time is comparable.
From main() to the first Flutter frame
After main(), the iOS-specific launch path begins:
// main.m (generated by Flutter)
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil,
NSStringFromClass([AppDelegate class]));
}
}UIApplicationMain is UIKit's entry point. It:
- Creates the
UIApplicationsingleton - Creates your
AppDelegate(which isFlutterAppDelegatein a Flutter app) - Loads the main storyboard or creates the initial UI programmatically
- Calls
application:didFinishLaunchingWithOptions:
FlutterAppDelegate.application:didFinishLaunchingWithOptions: is where the Flutter engine starts:
[~0ms] UIApplicationMain creates UIApplication, AppDelegate
[~10ms] FlutterAppDelegate.didFinishLaunchingWithOptions:
└─ Creates FlutterEngine
└─ FlutterEngine loads Flutter.framework symbols
└─ Creates platform, UI, raster, I/O threads
[~50ms] FlutterEngine starts Dart VM
└─ Maps App.framework (your compiled Dart code)
└─ Loads Dart heap snapshot
└─ Creates root isolate
[~80ms] Dart main() begins executing
└─ runApp(MyApp())
└─ Widget tree builds
└─ Layout computes
└─ Paint produces layer tree
[~180ms] First Flutter frame rendered
└─ Impeller → Metal → GPU → CALayer → display
└─ Launch storyboard transitions to Flutter contentThe launch storyboard (splash screen)
iOS requires every app to have a Launch Storyboard — a static UI that the system renders before the app's code runs. This is iOS's equivalent of Android's splash screen theme, but it's implemented differently.
When SpringBoard launches your app, it immediately renders the launch storyboard from the app's bundle, without executing any of your code. The user sees this storyboard within ~100ms of tapping the icon. It stays on screen until your app's first real UI is ready.
For Flutter apps, the launch storyboard is defined in ios/Runner/Base.lproj/LaunchScreen.storyboard. By default, it shows a white screen. The flutter_native_splash package generates a customised storyboard with your branding.
The key insight: the launch storyboard is rendered by SpringBoard (a different process), using UIKit (system framework, already in the shared cache). It's fast because it uses no app code and no Flutter rendering. The handoff to Flutter's first frame is where the user might see a flash — the storyboard is system-rendered, the first Flutter frame is Impeller-rendered, and if they don't match visually, the transition is visible.
Launch closure: pre-computed linking
dyld on iOS 13+ creates a launch closure — a cached computation of all the linking work needed for your app. The first launch after installation is slightly slower because dyld computes the closure (which libraries to load, which symbols to bind, which initialisers to run). Subsequent launches load the pre-computed closure, skipping the computation.
The launch closure is stored in /private/var/containers/Data/Application/<UUID>/Library/Caches/ and is specific to your app's binary. An app update invalidates the closure and triggers a recomputation on the next launch.
This is why the first launch after an iOS update or app update is measurably slower than subsequent launches — dyld is building a new closure.
Warm launch vs cold launch
iOS distinguishes between launch types:
Cold launch — the app is not in memory at all. The full launch path runs: process creation, dyld, engine loading, Dart VM start, first frame. This is what happens after a reboot, after the app has been killed by Jetsam (Post 4), or after the user force-quits it.
Warm launch — the app's process was terminated, but its pages are still in the kernel's page cache (the disk data is still in RAM). dyld needs to create the process and load the frameworks, but the physical reads are served from the page cache instead of flash storage. Warm launch is typically 50-100ms faster than cold launch.
Resume — the app's process is alive and in the background. No launch needed — the system brings the existing process to the foreground. The Flutter engine resumes rendering. This is nearly instant (~50ms for the first frame to render after resume).
The distinction matters because your app's perceived performance depends heavily on which type of launch the user experiences. On devices with limited RAM (older iPhones), cold launches happen more frequently because Jetsam kills background processes aggressively to free memory.
iOS vs Android launch: a comparison
| Aspect | Android | iOS | |--------|---------|-----| | Process creation | fork() from Zygote | posix_spawn() (fresh process) | | Framework preloading | Zygote preloads ART + framework | dyld shared cache (pre-linked) | | Copy-on-write sharing | Fork gives child parent's pages | Shared cache mapped at same address | | Dynamic linking | linker resolves at load time | dyld + launch closure | | Flutter engine load | dlopen(libflutter.so) ~80-150ms | Framework load ~80-150ms | | Dart code load | dlopen(libapp.so) ~50-100ms | App.framework load ~50-100ms | | Splash screen | Native Android theme | Launch storyboard | | Total cold start | ~400-700ms | ~400-600ms |
The totals are surprisingly similar. Android's Zygote gives a head start on framework loading, but iOS's shared cache and launch closure compensate. The Flutter-specific portion (engine load, Dart VM start, first frame) is nearly identical because it's the same engine and the same Dart code on both platforms.
Optimising Flutter launch on iOS
The optimisation strategies are mostly the same as Android (minimise main() work, keep the first screen simple, match the launch storyboard to the app's initial state), but iOS has some platform-specific considerations:
Minimise embedded framework count. Each embedded framework (each plugin with native iOS code) adds to dyld's loading work. Thirty plugins with native code means thirty frameworks to load, map, and initialise. Audit your dependencies and remove unused plugins — the launch time impact is real.
Avoid `+load` methods. Objective-C +load methods run during dyld initialisation, before main(). If a plugin uses +load for setup, that work is on the critical launch path. Modern plugins should use +initialize (lazy, called on first use) or explicit registration instead.
Pre-warm with `FlutterEngine`. For advanced cases, you can create a FlutterEngine in the AppDelegate and start the Dart entrypoint before the FlutterViewController is displayed. This parallelises engine initialisation with the native UI setup.
Profile with Instruments. Xcode's Instruments has an "App Launch" template that shows the full launch timeline: dyld time, framework loading, initialiser execution, and time to first frame. This is the definitive tool for iOS launch performance.
The fundamental constraint remains the same as Android: the Flutter engine and Dart VM must load from scratch on every cold launch. iOS can't preload them (they're not part of the shared cache, because they're not system frameworks). This is the structural cost of cross-platform — the engine loading time that neither platform's optimisations can eliminate.
Post 3 covers what happens after launch: the app lifecycle states that iOS enforces, and the strict rules about what your app can and cannot do in each state.
This is Post 2 of the iOS Under the Surface series. Previous: XNU: The Hybrid Kernel. Next: The App Lifecycle: Five States and Hard Rules.