How do you keep heavy work off the main thread on Android — and what happens when you don't?
The main thread has one job — draw the next frame in time — and every blocking call you put on it is a debt that comes due as a dropped frame or an ANR.
LEARN BY DOINGPut your intuition to work3 steps
How do you keep heavy work off the main thread on Android — and what happens when you don't?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Why the main thread is special
Android's main thread — also called the UI thread — is where the framework dispatches input events, runs View measure/layout/draw, and where Activity/Fragment lifecycle callbacks and most framework callbacks land by default. It is a single thread with a Looper pulling messages off a queue, one at a time, in order. Nothing about that is inherently slow, but it means the thread can only do one thing at once: if you hand it a network call, a large JSON parse, a database query, or a tight loop over a big list, every other message behind it — including the next frame's drawing work — waits until that call returns.
The system gives you roughly 16ms per frame at 60Hz (or roughly 11ms at 90Hz, 8ms at 120Hz — increasingly common on mid-range Android hardware) to do all the work needed to present the next frame. Miss that window and the frame is delayed — that's jank. Miss it for about 5 seconds while handling input or a broadcast, and Android's watchdog fires an ANR (Application Not Responding) dialog, which is one of the most damaging things that can happen to a Play Store rating because it is the one failure mode a user cannot mistake for "the app has a bug" — it reads as "the app is frozen."
#What actually belongs off the main thread
- Disk I/O — file reads/writes,
SharedPreferences.commit()(note:apply()is async on the calling thread, but a naivecommit()in anonClickblocks it), and any Room/SQLite query not markedsuspendor run via a background executor. - Network calls — always asynchronous in modern stacks (Retrofit + coroutines, OkHttp callbacks), but easy to get wrong if someone adds a
.execute()instead of.enqueue()or blocks on aDeferredwith.await()called from the wrong scope. - CPU-heavy work — image decoding/resizing (Bitmap operations are notorious), JSON/protobuf parsing of large payloads, sorting or filtering large collections, cryptography.
- Anything with an unpredictable duration — a third-party SDK call whose latency you don't control is a main-thread liability even if it "usually" returns fast.
#The tools, concretely
Kotlin coroutines with structured concurrency are the modern default:
class UserProfileViewModel(private val repository: UserRepository,) : ViewModel() {private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()fun loadProfile(userId: String) {viewModelScope.launch {_uiState.value = ProfileUiState.Loadingtry {// Dispatchers.IO moves the blocking network/DB call off the// main thread; withContext suspends here and resumes back// on the caller's dispatcher (Main) when the block returns.val profile = withContext(Dispatchers.IO) {repository.fetchProfile(userId) // Retrofit suspend call + Room read}_uiState.value = ProfileUiState.Loaded(profile)} catch (e: IOException) {_uiState.value = ProfileUiState.Error(e.message ?: "Network error")}}}}
viewModelScope ties the coroutine's lifetime to the ViewModel, so navigating away cancels in-flight work automatically — no leaked callback updating a dead Activity. Dispatchers.IO is a thread pool sized for blocking I/O; CPU-bound work (large list sorting, image transforms) should use Dispatchers.Default instead, which is sized to the number of cores.
For CPU-heavy work outside a coroutine-friendly API, WorkManager handles deferrable background work that should survive process death (uploads, periodic sync), while a plain Executor/HandlerThread still shows up in older or performance-critical codebases for tightly controlled background threads.
#What happens when you get it wrong
// Anti-pattern: synchronous network + parsing directly in onCreateoverride fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_profile)val json = URL("https://api.example.com/profile").readText() // blocks the main threadval profile = Gson().fromJson(json, Profile::class.java) // more main-thread workbinding.nameText.text = profile.name}
On a fast connection this might "work" in a demo and still be wrong — on a real network with latency variance, this either triggers Android's NetworkOnMainThreadException (the OS explicitly guards against this one case) or, for CPU work that isn't network I/O, silently eats frame budget with no exception at all, which is worse because nothing crashes — the app just visibly stutters, and the input dispatcher eventually queues up enough unhandled touch events to trip the ANR watchdog.
#How you diagnose it after the fact
Android Studio's Profiler (or standalone Perfetto/systrace) shows the main thread's timeline directly — long unbroken bars during a scroll or tap are the smoking gun, and StrictMode (StrictMode.setThreadPolicy) can be configured in debug builds to flag disk/network access on the main thread as it happens, turning a silent stutter into a loud Log warning or crash during development, well before it reaches a real device in the field.