HomeDocumentationAndroid Under The Surface
Android Under The Surface
12

Your App Is a Linux Process

Your Flutter App Is a Linux Process — What That Means

March 26, 2026

You've been thinking about your app in terms of widgets. A Scaffold wrapping a Column wrapping a ListView. A BLoC emitting states. A router pushing pages. This is the world you work in every day, and it's real — these abstractions do what they promise.

But the operating system has never heard of any of them.

From Android's perspective, your entire Flutter application — every widget, every animation, every network request, every isolate — is a single entry in a table. It has a number. The OS can pause it, resume it, or kill it. It does not know or care what a StatefulWidget is.

That entry is a process. And understanding what a process is, concretely, changes how you reason about everything from memory to background behaviour to why a native crash in a plugin takes down your entire app.

What a process actually is

A process is a running instance of a program. Not the program itself — the program is a file on disk (your APK, or more precisely, the libapp.so and libflutter.so inside it). A process is what the kernel creates when it starts executing that program.

Every process has a handful of properties the kernel tracks:

A PID — Process ID. A number. Unique while the process is alive. When you see "PID 14823" in a crash log, that's your app's process. When the process dies and a new one starts, it gets a different PID.

A UID — User ID. On a desktop Linux machine, UIDs separate human users. Alice is UID 1000, Bob is UID 1001. On Android, there are no human users in the traditional sense. Instead, UIDs separate apps. When you install your Flutter app, Android assigns it a UID — something like u0_a143, which maps to the numeric UID 10143. Every file your app creates is owned by that UID. Every process your app spawns runs under that UID.

This is not a framework feature. It's a kernel feature. The Linux kernel enforces file permissions based on UIDs. When your app tries to open a file owned by UID 10144 (a different app), the kernel's open() syscall handler checks the UID, sees the mismatch, and returns EACCES — permission denied. Your Dart code gets a FileSystemException. The sandbox isn't an Android abstraction layered on top of Linux. The sandbox is Linux.

A virtual address space. The process's private view of memory. If you've read the Memory Map article from the FFI series, this is the thing that diagram was drawing — the stack, the heap, the code segment, the memory-mapped region. Every process gets its own. Address 0x7FFE0000 in your process and address 0x7FFE0000 in another process point to completely different physical memory. The kernel maintains this illusion through page tables, and it's the reason one process can't read another's memory — the addresses don't overlap.

File descriptors. Every open file, every network socket, every pipe, every connection to a system service — the kernel tracks them as numbered entries in a per-process table. File descriptor 0 is standard input. File descriptor 3 might be your SQLite database. File descriptor 17 might be a TCP socket to your API server. When the process dies, the kernel closes all of them.

Threads. A process starts with one thread. Your Flutter app creates several more. Each thread can execute independently, but they all share the same address space — the same memory. We'll come back to this.

What your Flutter app looks like from `adb shell`

Theory is useful. Seeing it is better.

Connect your phone via USB, start your Flutter app, and open a terminal:

bash
adb shell ps -A | grep your.package.name
javascript
u0_a143  14823  812 15284672 128456 0  0 S your.package.name

That's your app. u0_a143 is the UID. 14823 is the PID. 812 is the PPID — the parent process ID. On Android, this is almost always Zygote's PID (more on that in Post 2). 128456 is the RSS in kilobytes — the actual physical memory your process is using right now.

Go deeper. The /proc filesystem is a virtual filesystem where the kernel exposes information about every running process:

bash
adb shell cat /proc/14823/status
javascript
Name:   your.package.name
State:  S (sleeping)
Tgid:   14823
Pid:    14823
PPid:   812
Uid:    10143   10143   10143   10143
Threads: 27
VmSize: 15284672 kB
VmRSS:  128456 kB

Twenty-seven threads. Your Dart code runs on one of them. The Flutter engine's raster thread is another. The I/O thread is another. Platform channel handlers run on the main (platform) thread. The rest are Dart VM internal threads, ART threads, and Binder threads waiting for incoming calls.

Now the memory map — the real version of that diagram from the FFI series:

bash
adb shell cat /proc/14823/maps | head -30
javascript
5580000000-5580004000 r--p 00000000 fd:06 262178  /data/app/~~abc/your.package.name-def/lib/arm64/libapp.so
5580004000-5580a8c000 r-xp 00004000 fd:06 262178  /data/app/~~abc/your.package.name-def/lib/arm64/libapp.so
7a8c000000-7a8d200000 rw-p 00000000 00:00 0        [anon:dart heap new space]
7a8d200000-7a92000000 rw-p 00000000 00:00 0        [anon:dart heap old space]
7b3c000000-7b3c800000 r-xp 00000000 fd:06 262176  /data/app/~~abc/your.package.name-def/lib/arm64/libflutter.so

There it is. libapp.so — your compiled Dart code, mapped into the code segment (r-xp means read + execute, no write). The Dart heap — new space and old space, exactly as described in the GC article, visible as anonymous mapped regions. libflutter.so — the Flutter engine. This isn't a conceptual diagram anymore. It's the actual memory layout of your running app, as the kernel sees it.

And file descriptors:

bash
adb shell ls -la /proc/14823/fd | head -20

Each entry is a symbolic link showing what the descriptor points to. You'll see /dev/binder (the Binder IPC device — Post 4), socket connections, your SQLite database file, and anon_inode:[eventpoll] (the epoll instance that powers the event loop). Every I/O operation your Flutter app does is represented here as a numbered file descriptor.

The UID model: every app is a user

This deserves emphasis because it's the most consequential design decision in Android's architecture.

On a traditional Linux server, you might have three human users (UIDs 1000, 1001, 1002) and dozens of processes — multiple processes per user. The kernel enforces that User A can't read User B's files, but all of User A's processes can read User A's files.

Android inverts this. There's (typically) one human using the device, but each app gets its own UID. Your app is UID 10143. Chrome is UID 10087. WhatsApp is UID 10201. From the kernel's perspective, they're different users.

This means the kernel's standard Unix permission model — the one designed in the 1970s to separate university students sharing a mainframe — is what separates apps on your phone. Your app's data directory (/data/data/your.package.name/) is owned by UID 10143 with permissions rwx------. The kernel won't let UID 10087 (Chrome) open any file in that directory. Not because Android's framework says so — because the kernel says so, at the syscall level, before any Java code is involved.

This is why rooting a device is a security concern. Root (UID 0) bypasses all permission checks. An app running as root can read every other app's files, because the kernel's permission enforcement doesn't apply to UID 0. The sandbox is Linux file permissions. Root breaks Linux file permissions. Everything in the security series about secure storage, Keychain, and encrypted preferences exists because the data at rest needs to survive even if the UID-based sandbox is compromised.

Threads inside the process

A process starts with a single thread — the main thread. In Android, this is also called the UI thread (confusingly, Flutter has its own "UI thread" which is a different thread — we'll untangle this).

Your Flutter app's process typically has 20-30 threads. The important ones:

The platform thread (Android's main thread). This is the thread that Android's framework runs on — Activity lifecycle callbacks, platform channel handlers, the native View system. When a Flutter plugin's Kotlin code handles a method channel call, it runs here. If you block this thread for too long, Android shows the "Application Not Responding" dialog.

The UI thread (Flutter's). This is where the Dart VM runs your build() methods, your BLoC logic, your state management. Despite the name overlap with Android's "UI thread," this is a separate thread. The Flutter engine creates it.

The raster thread. Takes the layer tree produced by the UI thread and turns it into GPU commands. This is where Impeller does its work on Android.

The I/O thread. Handles image decoding, asset loading, and other I/O operations that shouldn't block the UI or raster threads.

Dart isolate threads. Each Isolate.spawn() or compute() call creates a new OS thread. The isolate has its own Dart heap within the same process address space.

All of these threads share the same virtual address space. Thread 1 can read memory that Thread 2 wrote, because they're in the same process. This is simultaneously the power and the danger of threads — sharing memory is fast but requires synchronisation to avoid data races.

Dart isolates solve this differently: each isolate gets its own Dart heap within the shared process address space. The Dart VM enforces that Isolate A cannot access Isolate B's heap objects, even though they're in the same process memory. The isolation is enforced by the VM, not by the kernel. It's a software boundary, not a hardware one. This is why Isolate.spawn() creates a new thread (visible in /proc/<pid>/task/) but not a new process (no new PID).

bash
adb shell ls /proc/14823/task/
javascript
14823  14830  14831  14832  14833  14834  14835  14836  ...

Each number is a thread ID. The first one (14823) matches the PID — that's the main thread. The rest are the threads the Flutter engine, the Dart VM, and ART (Android Runtime Environment) created.

The process as a boundary

The process boundary is where isolation happens. This has practical consequences that show up in Flutter development regularly.

A native crash kills the whole app. If a C++ plugin has a segfault, the kernel sends SIGSEGV to the process. The process dies. All threads die. Your Dart code, your UI, your state — gone. This is why a bug in a poorly written native plugin can't be caught by a Dart try/catch — it's not a Dart exception. It's a signal from the kernel to the process.

Background apps are killed by killing the process. When Android needs memory, it doesn't ask your app to free some. It terminates the process. Your dispose() methods don't run. Your onDestroy() might not run. The process is simply gone, and the kernel reclaims all its resources — memory, file descriptors, threads, everything. This is why state restoration matters in Flutter, and why relying on in-memory state for critical data is risky.

Platform channels stay within the process. A platform channel call from Dart to Kotlin doesn't cross a process boundary. It crosses a thread boundary (from the Dart UI thread to the Android platform thread), which involves thread synchronisation but not kernel IPC. This is fast — microseconds, not milliseconds. The cost comes when the Kotlin handler then calls a system service, which does cross a process boundary via Binder (Post 4).

`dumpsys meminfo` reports process-level numbers. When you run adb shell dumpsys meminfo your.package.name, every number you see — Java Heap, Native Heap, Code, Graphics, Stack — is scoped to your process. The Dart heap is inside "Native Heap." Your compiled Dart code is inside "Code." The images Impeller has decoded are inside "Graphics." Understanding that these are all regions within a single process's virtual address space makes the numbers meaningful instead of mysterious.

What happens when your app starts

The user taps your app icon. Here's what the OS does:

  1. The launcher app sends a startActivity intent to the system server (a process called system_server, which runs all of Android's core services).
  2. ActivityManagerService (inside system_server) decides a new process is needed for your app.
  3. AMS sends a request to Zygote — a special process that exists specifically to spawn app processes quickly. How Zygote does this is the subject of Post 2, but the short version: it fork()s itself, creating a child process that inherits a pre-warmed Android runtime.
  4. The new child process gets a PID from the kernel. Its UID was determined at install time (10143).
  5. The new process creates FlutterActivity, which initialises FlutterEngine, which loads libflutter.so and libapp.so into the process's address space.
  6. The Flutter engine starts the Dart VM, which begins executing your main() function.
  7. runApp() builds the widget tree. The first frame renders. The Flutter view replaces the native splash screen.

              From the kernel's perspective, steps 5-7 are indistinguishable from any other code running inside a process. The kernel doesn't know Flutter exists. It sees a process using CPU cycles, allocating memory, opening file descriptors, and making syscalls. That's all a process is, from below.

              Why any of this matters

              A Flutter developer who understands the process model understands something that most Flutter developers don't: what their app is, from the perspective of the system that's running it.

              This understanding doesn't change your widget code. It changes how you debug. When dumpsys meminfo shows 280MB of native heap, you know what native heap means — it's a region in your process's virtual address space, and the Dart GC heap is inside it. When your app is killed in the background, you know what happened — the kernel terminated your process, reclaiming everything. When a native plugin crashes your app, you know why Dart can't catch it — a SIGSEGV is a kernel signal to the process, not a Dart exception.

              The process is the container your code lives in. Everything above it — widgets, BLoCs, isolates — exists inside it. Everything below it — the kernel, hardware, other processes — exists outside it. The boundary between inside and outside is where most of the interesting problems live.

              Post 2 looks at how that container gets created in the first place — and why Android's approach to creating it is one of the cleverest optimisations in mobile operating systems.

              Related Topics

              android process modelflutter linux processandroid uid sandboxadb shell processflutter app internalsandroid process isolationproc pid flutter

              Ready to build your app?

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