The main thread
Every app process has one thread that does everything the user can see. Blocking it is not slow code — it is an unresponsive app, by definition.
An Android app has one thread that draws, handles input, runs lifecycle callbacks and executes almost
all of your code. The platform calls it main, the documentation calls it the UI thread, and every
performance property the user perceives is a property of how long your code holds it.
TL;DR#
- Each app process has one main thread, created by
ActivityThread. It runs the that dispatches lifecycle callbacks, input and drawing. - Nothing else runs concurrently on it. A slow function delays the next frame, the next tap and the next lifecycle callback, in that order of visibility.
- The frame budget at 60 Hz is about 16.7 ms for everything: input, animation, measure, layout, draw and the app's own work.1
- An ANR is not "slow"; it is a missed deadline. Input dispatch gives roughly 5 seconds, a broadcast about 10.2
- The fix is almost never "make it faster". It is move it off the main thread and leave a thin callback behind.
Mental model#
Picture a shop with one counter clerk.
Customers queue. Each customer gets a fixed amount of time before the queue becomes visibly slow. The clerk can serve a customer quickly, or hand the paperwork to the back office and take the next customer while it is being processed. What the clerk must never do is walk into the back office and do the paperwork personally — the queue outside stops, and eventually the manager comes out to ask what is going on.
Where the analogy breaks#
A real clerk can choose to work faster under pressure. The main thread cannot: it has a fixed amount of work per frame, and "working faster" is not an available option. Also, the back office in Android does not automatically come back to the counter — you have to post the result back explicitly, and forgetting that is how apps touch UI from the wrong thread and crash.
The big picture#
Text description of this diagram
An interactive MessageQueue simulator drawn as a conveyor belt. Messages posted to the main thread's Looper are ordered by due time. postDelayed places a message further down the belt, postAtFrontOfQueue jumps ahead, and postSyncBarrier holds synchronous messages while asynchronous ones pass. Dispatching a message shows how long the main thread stays busy: work that overruns the 16 ms frame budget produces jank, and a multi-second task produces an ANR because input can no longer be serviced. A worker thread can post into the same queue, which is how background results reach the main thread.
Press "parse 2 MB JSON" and dispatch: 6000 ms of work on the main thread. The frame budget bar turns red immediately, and the ANR banner appears because input dispatch has missed its deadline. Now imagine the same work handed to a worker, with only the result posted back — six seconds of background time and no missed frames.
Deep dive#
What the main thread actually runs#
The main thread is created in ActivityThread.main(), which prepares the process's Looper and then
calls Looper.loop() — an infinite loop that never returns. Everything you think of as "your app" is a
message being dispatched from that loop.3
That means the main thread runs, in one interleaved sequence:
- lifecycle callbacks (
onCreate,onResume, …); - input events, dispatched from
InputDispatchervia the app'sInputEventReceiver; - frame callbacks, including measure, layout and draw;
Handlermessages you or the framework posted;Runnables posted throughView.post,Activity.runOnUiThread, coroutines onDispatchers.Main.
There is no preemption between those. A message runs to completion before the next one starts, which is why a single slow function delays everything queued behind it.
The frame budget#
At 60 Hz the display asks for a new frame every 16.67 ms. At 120 Hz, about 8.3 ms. Everything the app does for a frame has to fit in that window:
input dispatch → animation → measure → layout → draw → sync to RenderThread → GPU → compositeMost of that work happens on the main thread until the sync step, at which point RenderThread takes
over. So the main thread's budget is smaller than the frame period: it must finish early enough for
RenderThread and SurfaceFlinger to complete their work before the vsync deadline.4
The ANR thresholds#
An ANR is the system's conclusion that the app can no longer respond. Three deadlines matter:
| Situation | Deadline (approximately) |
|---|---|
| Input event not handled | 5 s |
| Broadcast receiver not finished | 10 s foreground, 60 s background |
| Service start not completed | 20 s foreground, 200 s background |
When the deadline passes, system_server collects a trace of every thread in the process and shows
the dialog. That trace — /data/anr/, and the ANR section of a bug report — is the only reliable
evidence of what was blocking.2
// The classic ANR: a network call on the main thread.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val user = api.fetchUserBlocking() // never do this
render(user)
}The stack trace from that ANR will point at the socket read, which is helpful. The more common case is
worse: a synchronized block contending with a background thread, or a database query triggered from
a ContentObserver. Those traces point at a lock, and the cause is in another thread.
StrictMode: the seatbelt#
StrictMode is a debug-only tool that watches the main thread and reports exactly the things this page is about: disk reads, network calls, leaked closeables, and slow calls.
class NotesApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.detectCustomSlowCalls()
.penaltyLog()
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectLeakedClosableObjects()
.detectLeakedSqlLiteObjects()
.penaltyLog()
.build()
)
}
}
}Every violation appears in logcat with a stack trace at the point of the call. Turning it on for a day in a real app usually surfaces a dozen accidental main-thread I/O calls that no code review caught.
What "off the main thread" actually means#
Moving work off the main thread does not remove the work; it moves the constraint. The rules:
- Do the work on another thread or dispatcher. A worker, a thread pool,
Dispatchers.IO,Dispatchers.Default. - Post the result back. The main thread is the only thread allowed to touch views (before Compose) or to mutate Compose state that a composition reads.
- Keep the handoff small. Post values, not object graphs that need further processing.
class NotesViewModel(
private val repository: NotesRepository,
) : ViewModel() {
private val _state = MutableStateFlow<NotesUiState>(NotesUiState.Loading)
val state: StateFlow<NotesUiState> = _state.asStateFlow()
fun load() {
viewModelScope.launch { // starts on Main
_state.value = NotesUiState.Loading
val notes = withContext(Dispatchers.IO) { repository.loadAll() }
_state.value = NotesUiState.Ready(notes) // resumes on Main
}
}
}That is the shape every correct implementation converges on: launch on main, hop to a background
dispatcher for the blocking part, resume on main to publish. withContext is the hop; viewModelScope
is the lifetime.
The main thread is also where lifecycle runs#
A subtle consequence: because lifecycle callbacks run on the main thread, anything they call
synchronously is main-thread work. That includes SharedPreferences reads, ContentResolver queries,
file existence checks, and reflection-heavy deserialisation. A ViewModel factory that reads a config
file in its constructor is doing main-thread I/O, even though it looks like "just a constructor".
The Compose equivalent is recomposition: it runs on the main thread, so a composable that does a
synchronous database read will jank exactly like an onDraw would.
Why the main thread is not just a thread#
The main thread is special in three ways that no amount of careful coding changes:
- It is the only thread with a
Looperprepared by the framework, so it is the only one that receives lifecycle callbacks and input. - It is the thread that owns the window, so only it can apply view changes or commit a composition.
- It is the thread the system measures for ANRs. A blocked worker is invisible; a blocked main thread is an ANR.
Those three properties are why "move it off the main thread" is not a performance suggestion but a correctness requirement for anything that can take more than a few milliseconds.
Measuring it#
Three tools, in increasing order of precision:
dumpsys gfxinfo <package> framestats— per-frame timing buckets, including how long the app spent on the main thread.- Perfetto / system tracing — a timeline of every thread, with slices for
Choreographer#doFrame,measure,draw, and Binder calls. - Macrobenchmark — a reproducible measurement of startup and frame timing on a real device.5
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "com.example.notes",
metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD,
) {
pressHome()
startActivityAndWait()
}A number from that test is worth more than any amount of reasoning about which code is "probably slow".
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 3 | 1.5 Cupcake |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 12 | 3.1 Honeycomb |
|
| 16 | 4.1 Jelly Bean |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 33 | 13 Tiramisu |
|
| 36 | 16 Baklava |
|
The single-threaded UI model has never changed. What changed is the tooling for measuring it (Choreographer in 4.1, tracing in 4.3, Perfetto later) and the arrival of a UI toolkit — Compose — whose recomposition also runs on that thread.
Under the hood#
- ActivityThread.javamain(), the Looper, and the dispatch of every component callback
- Choreographer.javathe vsync-driven scheduler for input, animation and traversal
- Looper.javathe loop that never returns
- AppNotRespondingDialog.javathe system's ANR surface
- StrictMode.javathe main-thread policy checker
Curiosity corner#
- ANR traces include every thread, not just the blocked one. The interesting part is usually the thread holding the lock the main thread is waiting for.
- An ANR can happen with a completely idle main thread. If input is delivered to a window whose app is not responding to the input channel, the deadline still fires.
Choreographerwas added in Android 4.1 as part of Project Butter, and it is still the scheduling point for every frame.
Try it#
adb shell dumpsys gfxinfo com.example.notes framestats
adb shell perfetto -o /data/misc/perfetto-traces/trace -t 5s sched freq idle am wm gfx view
adb pull /data/misc/perfetto-traces/trace
Check yourself#
Check yourself
A function does 300 ms of work on the main thread. What is the most accurate description of the consequence?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsการแสดงผลช้า | App qualitydeveloper.android.comaccessed 2026-10-27↩
- docsAnrdeveloper.android.comaccessed 2026-10-27↩
- docsProcesses and threads overview | App qualitydeveloper.android.comaccessed 2026-10-27↩
- docsRendering | App qualitydeveloper.android.comaccessed 2026-10-27↩
- docsMacrobenchmark overviewdeveloper.android.comaccessed 2026-10-27↩
- docsPerformance overviewdeveloper.android.comaccessed 2026-10-27↩
- docsOverview of system tracingdeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 16 or higherdeveloper.android.comaccessed 2026-10-27↩