HomeDocumentationAndroid Under The Surface
Android Under The Surface
12

Zygote: How Android Launches Apps Fast

Android Zygote Explained for Flutter Developers

March 26, 2026

Tap an app icon. The app appears. It takes — what, half a second? Maybe less on a recent phone. The interaction feels trivial. Instantaneous, almost.

It is not instantaneous. Between your tap and the first frame, the operating system has to create a new process, initialise a runtime, load framework classes, create an Activity, inflate a layout, and render pixels. On a desktop OS, starting a process from scratch and loading a full application framework takes one to three seconds. Android does it in a fraction of that, on hardware with less memory and a slower CPU than most laptops.

The trick is called Zygote. And understanding it explains both why native Android apps start fast and why Flutter apps start measurably slower — and what you can do about the gap.

The cold start problem

Starting a process from nothing means:

  1. The kernel allocates a new virtual address space (page tables, kernel structures).
  2. The process loader maps the executable and its shared libraries into memory.
  3. The dynamic linker resolves symbols and runs initialisation routines.
  4. The runtime starts — for Android, this means the Android Runtime (ART), which manages Java/Kotlin code.
  5. ART loads and initialises the Android framework classes: Activity, View, Context, PackageManager, the resource system, the theming system — thousands of classes.
  6. The app's own code loads and runs.

Steps 1-3 are fast — the kernel is good at this. Steps 4-5 are the bottleneck. ART initialisation and framework class loading take hundreds of milliseconds, even on fast hardware. If every app launch paid this cost fresh, Android would feel sluggish.

fork(): the system call that changes everything

The solution comes from a Unix system call that's been around since the 1970s: fork().

fork() creates a copy of the calling process. The new process — the child — gets its own PID, its own entry in the process table, but it inherits the parent's memory, loaded libraries, open file descriptors, and runtime state. It's a clone, starting from exactly where the parent was at the moment of the fork.

The critical detail: fork() doesn't physically copy memory. It uses copy-on-write (CoW). After a fork, both parent and child share the same physical memory pages. The kernel marks these pages as read-only. As long as neither process writes to a page, they share it — zero copying cost. Only when one of them writes to a page does the kernel intercept the write, create a private copy of that page for the writing process, and then allow the write to proceed.

This means forking a process that has 300MB of loaded framework code doesn't cost 300MB. It costs almost nothing — just the kernel data structures for the new process. The 300MB of framework code is shared, read-only, and stays shared until the child process modifies something.

The cost of fork is proportional to how much the child changes, not how much the parent has.

Zygote: the warm parent

Android exploits fork() with a process called Zygote (from the Greek — the initial cell formed when an egg is fertilised, from which everything else develops).

Here's what happens at boot:

  1. The init process (PID 1, the first user-space process) reads its configuration and starts system services.
  2. One of those services is app_process64 (on 64-bit devices), which becomes Zygote.
  3. Zygote starts the Android Runtime (ART).
  4. Zygote preloads approximately 7,000 framework classes — Activity, Fragment, View, TextView, RecyclerView, Context, Intent, every standard Android class your app might need.
  5. Zygote preloads shared libraries — ICU (internationalisation), OpenGL ES bindings, media codecs.
  6. Zygote preloads common resources — system fonts, framework drawables, theme defaults.
  7. Zygote then enters a loop: wait for a command on a socket, fork when asked, repeat.

All of that preloading happens once, at boot. The cost is paid when the phone turns on, not when you tap an app icon.

When ActivityManagerService needs a new process for an app, it sends a command to Zygote over a Unix domain socket. Zygote calls fork(). The child process — your app — inherits everything: the running ART instance, all 7,000 preloaded classes, the shared libraries, the resources. Via copy-on-write, the child pays nothing for this inheritance until it starts modifying things.

The child then loads the specific app's APK, creates the app's Application class, and starts the requested Activity. The heavy framework loading was already done by the parent. The child only pays for what's specific to it.

The process tree

You can see this relationship directly:

bash
adb shell ps -A | grep -E "zygote|your.package"
javascript
root          812     1 14268480  89600 0  0 S zygote64
u0_a143     14823   812 15284672 128456 0  0 S your.package.name

Your app (PID 14823) has PPID 812 — its parent is Zygote. Every app on the device has the same parent:

bash
adb shell ps -A | grep " 812 "

You'll see system_server, Chrome, the launcher, your app — all children of Zygote. The entire Android application ecosystem is a family tree rooted in a single process that loaded the framework once and shared it with everyone.

If you want to see it as a tree:

bash
adb shell ps -A --ppid 812
javascript
system_server (PID ~350)
com.android.launcher3 (PID ~1200)
com.google.android.gms (PID ~1500)
com.android.chrome (PID ~8700)
your.package.name (PID 14823)
...

Every app process is a fork of Zygote. They all started with the same 300MB of preloaded framework code, shared via copy-on-write.

Where Flutter doesn't benefit

Here's the less exciting part.

Zygote preloads ART and the Android framework — Activity, View, Canvas, RecyclerView, the layout inflater, the resource system. A native Kotlin app uses all of this directly. Its first Activity is a real Activity subclass. Its UI is built from real View subclasses. The framework classes it needs are already in memory, warm, ready.

Flutter uses almost none of it.

A Flutter app's Activity — FlutterActivity — is a thin shell. It creates a FlutterView (a SurfaceView), which hosts the Flutter engine. The engine has its own rendering pipeline, Impeller, its own widget framework, its own layout system, its own runtime (the Dart VM). None of these are preloaded by Zygote, because they're not part of the Android framework. They're Flutter's own code, in libflutter.so.

So when your Flutter app's process forks from Zygote, the timeline looks like this:

pseudo
| Step | Native Kotlin app | Flutter app |
|------|-------------------|-------------|
| Fork from Zygote | ~5ms | ~5ms |
| Load app's DEX code | ~50ms | ~50ms (thin) |
| Create Activity | ~20ms | ~20ms |
| Framework class init | Already loaded (0ms) | N/A — Flutter has its own |
| Load Flutter engine | N/A | **~80-150ms** (`libflutter.so`) |
| Load Dart code | N/A | **~50-100ms** (`libapp.so`) |
| Start Dart VM | N/A | **~30-50ms** |
| First Dart frame | N/A | **~100-200ms** (`main()` → `runApp()` → build → render) |
| **Total to first frame** | **~200-400ms** | **~400-700ms** |

The numbers are approximate and vary by device, but the pattern is consistent: Flutter has an extra ~200-400ms of startup work that can't benefit from Zygote, because it's loading its own engine and runtime from scratch.

This is the structural reason Flutter cold start is slower than native. It's not because Dart is slow — Dart's AOT-compiled code runs at near-native speed once it's loaded. It's because the loading itself — mapping libflutter.so into memory, initialising the Dart VM, JIT-warming the first frame — is work that Zygote can't help with.

The native splash screen

Android's answer to the "gap between tap and first frame" is the splash screen. When FlutterActivity starts, Android immediately shows its window with the theme's background — either a solid colour or a drawable defined in styles.xml.

This happens before the Flutter engine loads. It's native Android rendering, using the framework classes that Zygote preloaded. The user sees something instantly, even though Flutter hasn't produced a single pixel yet.

When Flutter's first frame is ready, the FlutterView replaces the splash. If the splash matches your app's initial screen well enough, the transition is seamless. If it doesn't, there's a visible flash.

The flutter_native_splash package generates the splash screen configuration automatically from your Flutter assets. It's not doing anything magical — it's creating the Android XML resources and theme attributes that define what the native Activity window shows before Flutter takes over.

Understanding this explains a confusion that trips up many Flutter developers: the splash screen and the Flutter app are rendered by two completely different systems. The splash is Android framework rendering (Zygote-preloaded, fast). The app is Flutter engine rendering (loaded from scratch, slower). The handoff is where things can go wrong.

Zygote64 and the 32-bit question

On modern 64-bit devices, there are two Zygote processes:

bash
adb shell ps -A | grep zygote
javascript
root    812    1  14268480  89600 0  0 S zygote64
root    813    1   1823456  67200 0  0 S zygote

zygote64 forks 64-bit app processes. zygote (32-bit) forks 32-bit app processes, for legacy apps that still ship 32-bit native libraries. Flutter apps are 64-bit, so they fork from zygote64.

The actual binary that becomes Zygote is app_process64 (or app_process32). This is what you see in profiling tools and strace output. When init starts Zygote, it executes /system/bin/app_process64, which initialises ART, preloads the framework, and becomes the Zygote we've been discussing.

The full timeline, end to end

From the user's tap to the first Flutter frame, with the OS-level detail:

javascript
[0ms]     User taps app icon
          └─ Launcher sends startActivity intent (Binder IPC to system_server)

[~5ms]    ActivityManagerService receives the intent
          └─ Looks up the target Activity (FlutterActivity)
          └─ Determines a new process is needed
          └─ Sends fork request to Zygote over Unix socket

[~10ms]   Zygote calls fork()
          └─ Kernel creates new process: PID 14823, UID 10143
          └─ Child inherits ART, framework classes, shared libraries (CoW)

[~15ms]   Child process loads app's APK via ClassLoader
          └─ DEX bytecode for FlutterActivity, Application class

[~40ms]   Application.onCreate() runs
          └─ FlutterActivity.onCreate() runs
          └─ Native splash screen is visible to user at this point

[~60ms]   FlutterActivity creates FlutterEngine
          └─ System.loadLibrary("flutter") → dlopen("libflutter.so")
          └─ 8-10MB of native code mapped into the process

[~180ms]  Flutter engine initialises
          └─ Creates platform, UI, raster, and I/O threads
          └─ Starts the Dart VM

[~250ms]  Dart VM loads libapp.so (your AOT-compiled Dart code)
          └─ Resolves Dart symbols, sets up isolate

[~300ms]  main() executes
          └─ runApp() → builds widget tree → layout → paint

[~450ms]  First Flutter frame rendered to the Surface
          └─ FlutterView replaces splash screen
          └─ User sees the app

These timings are for a mid-range device (Snapdragon 7-series, 6GB RAM). Flagship devices are faster. Budget devices are slower. But the proportions hold: roughly half the startup time is Zygote + Android framework (fast, optimised, benefits from preloading), and the other half is Flutter-specific loading (not preloaded, paid fresh every launch).

What you can do about it

Understanding the timeline tells you where to optimise:

Minimise `main()` work. Everything between main() and the first frame is on the critical path. Defer service locator registration, database migrations, and API pre-fetching to after the first frame. Use WidgetsBinding.instance.addPostFrameCallback to schedule non-critical initialisation.

Keep the first screen simple. A complex first screen with multiple API calls, image loading, and animations extends the time to first frame. A simple skeleton or loading state renders fast; the real content can load progressively.

The native splash screen is your friend, not a crutch. Design it to match your app's initial state. A well-matched splash makes a 500ms load feel like a 200ms load, because the user perceives continuity rather than a blank gap.

Deferred components (Android app bundles with deferred loading) can reduce the size of libapp.so that needs to load at startup, at the cost of complexity.

Profile with `--trace-startup`. flutter run --trace-startup captures a timeline from engine init to the first frame, showing exactly where your startup time is spent. The Flutter DevTools timeline view makes this visual.

None of these change the fundamental cost of loading the Flutter engine. That's structural. But they can reduce the Dart-side startup from 200ms to under 100ms, which on top of the engine load makes the total perceptibly fast.

The deeper point

Zygote is a systems-level optimisation that solves a real problem elegantly: amortise the cost of framework loading across all apps by doing it once and sharing via copy-on-write. It's one of the reasons Android can run dozens of apps on devices with limited memory — the framework code is loaded once in physical RAM and shared across every process.

Flutter sits in an unusual position: it runs inside the Android process model (fork from Zygote, UID sandbox, kernel-managed lifecycle) but brings its own runtime and framework that can't benefit from Zygote's preloading. It gets the isolation and lifecycle management for free. It pays for its own engine loading every time.

This isn't a flaw. It's a tradeoff. Flutter chose to own its rendering pipeline, which gives it pixel-perfect cross-platform consistency and independence from the Android framework's version fragmentation. The cost is that it can't piggyback on the framework preloading that Zygote provides. Whether that tradeoff is worth it depends on the app — but understanding why the cold start gap exists is more useful than trying to eliminate it without knowing what causes it.

Post 3 goes one level deeper: the kernel itself, and the system calls that your Dart code triggers every time it does anything with the outside world.

This is Post 2 of the Android Under the Surface series. Previous: Your App Is a Linux Process. Next: The Kernel and System Calls

Related Topics

android zygoteflutter cold startandroid app launchfork copy on writeflutter splash screenandroid process creationapp startup time flutter

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.