Zygote
Threads & concurrencybeginner

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.

11 minAPI 1–37 · 17interactivequizverified 2026-10-27

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#

Loading diagram…
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 InputDispatcher via the app's InputEventReceiver;
  • frame callbacks, including measure, layout and draw;
  • Handler messages you or the framework posted;
  • Runnables posted through View.post, Activity.runOnUiThread, coroutines on Dispatchers.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 → composite

Most 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:

SituationDeadline (approximately)
Input event not handled5 s
Broadcast receiver not finished10 s foreground, 60 s background
Service start not completed20 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:

  1. Do the work on another thread or dispatcher. A worker, a thread pool, Dispatchers.IO, Dispatchers.Default.
  2. 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.
  3. 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:

  1. It is the only thread with a Looper prepared by the framework, so it is the only one that receives lifecycle callbacks and input.
  2. It is the thread that owns the window, so only it can apply view changes or commit a composition.
  3. 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#

Interface changes16 releases
APIVersionWhat changed
11.0
  • Pull-down notification shade
  • Home screen with widgets
31.5 Cupcake
  • Home screen widgets and folders
92.3 Gingerbread
  • New UI with dark theme and green accents
113.0 Honeycomb
  • The Holo design language arrives
123.1 Honeycomb
  • Resizable widgets
164.1 Jelly Bean
  • Project Butter: vsync-driven, smooth UI
  • Expandable notifications
204.4W KitKat (Wear)
  • Voice-driven card UI
215.0 Lollipop
  • Material Design
  • Notifications on the lock screen
247.0 Nougat
  • Quick Settings tiles
  • Direct Reply in notifications
268.0 Oreo
  • Notification channels
289 Pie
  • Dark theme groundwork
2910
  • System-wide dark theme
3011
  • Conversation notifications and bubbles
  • Media controls in Quick Settings
3112
  • Material You dynamic colour
3313 Tiramisu
  • Themed app icons
  • Runtime notification permission
3616 Baklava
  • Material 3 Expressive
  • Live Updates notifications

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#

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.
  • Choreographer was added in Android 4.1 as part of Project Butter, and it is still the scheduling point for every frame.

Try it#

Frame statistics for an app

adb shell dumpsys gfxinfo com.example.notes framestats

Capture a trace and open it in Perfetto

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

1 / 12

A function does 300 ms of work on the main thread. What is the most accurate description of the consequence?

0 answered · 0 correct

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.

  1. docsการแสดงผลช้า | App qualitydeveloper.android.comaccessed 2026-10-27↩
  2. docsAnrdeveloper.android.comaccessed 2026-10-27↩
  3. docsProcesses and threads overview | App qualitydeveloper.android.comaccessed 2026-10-27↩
  4. docsRendering | App qualitydeveloper.android.comaccessed 2026-10-27↩
  5. docsMacrobenchmark overviewdeveloper.android.comaccessed 2026-10-27↩
  6. docsPerformance overviewdeveloper.android.comaccessed 2026-10-27↩
  7. docsOverview of system tracingdeveloper.android.comaccessed 2026-10-27↩
  8. docsBehavior changes: Apps targeting Android 16 or higherdeveloper.android.comaccessed 2026-10-27↩