Your Android App Isn't Compiled When You Install It
16 Jul 2026Install an app from the Play Store, tap its icon, and it opens and works right away the first time, with no “compiling…” step you ever see. So by the time you tap it, it must already be compiled to native machine code. That was my assumption, and the reasoning felt airtight: the CPU only runs native instructions, the app clearly runs, so someone must have compiled it.
I checked with adb, which can read an app’s compilation status. One second
after installing, here’s what it showed:
$ adb shell dumpsys package <package> | grep status=
arm64: [status=verify] [reason=install]
So verify means Android checked the bytecode and confirmed it’s valid, but
compiled none of it to native. The app is running by interpreting that
bytecode, and it works fine anyway.
The naive assumption: running means compiled
It sounds obvious. A CPU runs only native machine instructions, and your app is DEX bytecode, which isn’t native. So for the app to run, that bytecode has to become native code first. The first half of that is right: the CPU really does only run native instructions. The second half is where it falls apart, because your code doesn’t have to be the native code the CPU runs.
The missing piece is the interpreter, part of ART, the Android Runtime. The
interpreter is itself native code, and its job is to read your bytecode one
instruction at a time and do what each instruction says, so your code stays
bytecode the whole time while the interpreter does the CPU work for it. Think
of it like a book in a language you can’t read: a translator sits next to you
and reads it aloud, line by line, never rewriting the book, doing the work
live. That’s the interpreter, and it’s why “the app runs” doesn’t have to
mean “the app is native.” It can mean “a native interpreter is reading the
app’s bytecode,” which is exactly what verify is: the code is checked but
not compiled, and the interpreter runs it.
Three ways the same code reaches the CPU
Once you accept that compiling to native is optional, the picture opens up.
There are three routes from your bytecode to the CPU, and an app uses all
three at once: it can interpret the bytecode as-is, JIT it (compile a
hot method to native while the app runs), or AOT-compile it (turn methods
into native code ahead of time, with a tool called dex2oat). The diagram
traces all three down to the same CPU.
flowchart TD
DEX["Your method<br/>(DEX bytecode, not native)"]
DEX --> I["Interpret<br/>bytecode stays bytecode"]
DEX --> J["JIT compile<br/>hot method, while running"]
DEX --> A["AOT compile<br/>ahead of time, by dex2oat"]
I --> CPUi["CPU runs the INTERPRETER,<br/>which reads your bytecode"]
J --> CPUn["CPU runs YOUR method,<br/>now native"]
A --> CPUn
CPUi --> CPU["CPU: native instructions only"]
CPUn --> CPU
The trick is in the bottom row. Every route ends at the CPU as native code, and the only difference is whose native code it is. When interpreting, the native code the CPU runs is the interpreter, not your method, while with JIT or AOT it’s your own method, compiled. The difference between those two compiled routes is just timing: JIT pays the warm-up on every cold start, while AOT does the work ahead of time so the method is already native before the app even opens.
Watching the modes switch live, on a real app
This part was easier than I expected. You can force an installed app between these modes yourself and watch the state change. It works on any installed app, no root, including ones from the Play Store. So instead of a toy, I used a big real one already on my phone: LinkedIn.
The tool is cmd package compile. The -m flag picks the mode, -f forces
a recompile, and dumpsys package reads back the current one.
$ adb shell cmd package compile -m verify -f com.linkedin.android
Success
arm64: [status=verify] ← interpret + JIT only, no native
$ adb shell cmd package compile -m speed -f com.linkedin.android
Success
arm64: [status=speed] ← full AOT, everything native
verify leaves the app to interpret and JIT at runtime, with no native
code. speed means dex2oat compiled every method to native up front. Same
app, two answers to “how does this run,” switchable in a second.
I wanted to see the native code itself, not just a label. dex2oat writes
it to an .odex file whose path is hashed, so you pull the path from
dumpsys first and then stat it, which works on an unrooted phone even
though listing the directory doesn’t. Here’s the whole thing, forcing each
mode and measuring the file:
# grab the (hashed) .odex path once
$ odex=$(adb shell dumpsys package com.linkedin.android \
| grep -m1 'location is' | grep -o '/data/[^]]*\.odex')
$ adb shell cmd package compile -m verify -f com.linkedin.android
$ adb shell stat -c %s "$odex"
590592 # 0.56 MB, verification metadata, ~no native code
$ adb shell cmd package compile -m speed -f com.linkedin.android
$ adb shell stat -c %s "$odex"
217823560 # 208 MB, every method compiled to native
That’s not a typo. Compiling one real app fully produced 208 MB of native machine code, 369 times the size of the verify-only file. It’s not a metaphor or a diagram box, but a real file on disk that exists when you compile and vanishes when you don’t.
That number is also why no app ships this way. LinkedIn’s real state on my
phone was neither extreme. The mode that actually ships is the middle one,
speed-profile, which compiles only the methods a profile marks as hot:
$ adb shell cmd package compile -m speed-profile -f com.linkedin.android
$ adb shell stat -c %s "$odex"
53893752 # 51 MB, about a quarter the size of full speed
Multiply 208 MB by every app on a phone and you’d fill the disk with
compiled code, so speed-profile is the compromise that ships. It’s also
LinkedIn’s normal state, so running that last command just reset the app to
where it started.
Three things the device taught me that no article did
This is where running it paid off. Three results surprised me, and each one turned out to be correct behavior rather than a glitch.
One: a debug build refuses to AOT-compile. My first attempt used a debug
build. I ran -m speed, the command said Success, and the status stayed
at verify. It looked broken. It isn’t. ART keeps debuggable apps
interpretable on purpose, so a debugger can attach and step through them.
You can’t fully AOT-compile a debug build. I had to sign a release build
before -m speed produced speed.
Two: profile-guided compilation does nothing on a fresh app. There’s a
third mode, speed-profile, and it’s the default Android uses. It compiles
only the methods a profile marks as hot, not everything. I ran it on a
freshly installed app of my own that shipped no Baseline Profile, and the
status stayed at verify. Nothing compiled, and the reason is simple: a
profile records which methods ran hot in real use, and an app that has never
run has no profile, so speed-profile has nothing to compile. This is the
gap Baseline Profiles fill. They ship a ready-made profile inside the APK, so
speed-profile has something to work with from the first launch instead of
after several.
Three: fresh installs really do compile nothing to native. That opening
verify status wasn’t a fluke, it’s the default. Since Android 7, apps
install with no AOT compilation at all, so install just verifies the
bytecode, and the app then interprets, JITs the hot spots, and quietly
records a profile while you use it. Later, when the phone is idle and
charging, dex2oat runs on its own and compiles the hot methods, so the
next launch is faster because of work done while you weren’t looking. (A debug build gets even less. It
installs at run-from-apk, not even verified, because the OS leaves it
untouched so a debugger can attach.)
Why Google gave up compiling everything at install
It didn’t always work this way. On Android 5 and 6, dex2oat compiled the
entire app to native at install time, every method, up front. That made apps
run fast, but it had two costs that got worse as apps grew. Installs and
system updates were slow, because every app had to be fully recompiled
before you could use it, and the compiled output ate storage, because you
paid to compile code that many users would never run.
Android 7 traded that for the hybrid model above: install compiles nothing, JIT handles the warm-up, and AOT later compiles only what a profile proves is hot. You give up peak speed on the first few launches, but you get fast installs, less wasted storage, and compilation spent only on the code that’s really used.
That tradeoff is why your app runs interpreted the moment it installs, and why it isn’t native when you first tap it. It’s not an oversight. The interpreter carrying a fresh install is the design working as intended.
Worth remembering
The idea worth keeping is that verified, compiled, and fast are three
different things, and it’s easy to assume an app that clearly runs is all
three. A fresh install is only verified. Compilation is a separate step that
might not have happened yet, and even when it has, it only covers the methods
a profile marked as hot. None of this shows from the outside, because the app
runs fine in every state. The only way to know which one you’re in is to go
and measure it, which is exactly why two adb commands were more convincing
to me than any diagram I’d read.
All the status lines and sizes came from running these commands on a real,
unrooted phone (arm64), not from documentation. stat reads an app’s
.odex straight off the device even without root, though listing the
directory is blocked. adb shell cmd package compile -m <mode> -f <pkg>
forces a mode. adb shell dumpsys package <pkg> reads the current one. The
verify, speed-profile, and speed modes, and the Android 5/6 to 7 to 14
history, are documented at
developer.android.com
and source.android.com.
The numbers came from watching, not reading.