Background Work & Threading
Background Work & Threading
Every Android application starts with a single main thread — also called the UI thread. Android draws frames, dispatches touch events, and calls your lifecycle methods (like onCreate and onResume) on this thread. The rule is absolute: if the main thread is blocked for more than a few hundred milliseconds, Android shows an Application Not Responding (ANR) dialog and your user taps "Close app". If you do any network call, database query, or file read on the main thread, you will hit this wall.
This lesson teaches you how to move work off the main thread using ExecutorService — the modern, production-grade approach in Java Android development — and how to safely post results back to the UI thread when the work is done.
AsyncTask was Android's original off-thread helper, but it was deprecated in API 30 (Android 11) and officially removed in API 33. It had subtle lifecycle bugs that caused memory leaks and crashes on rotation. The modern replacement is ExecutorService paired with Handler or LiveData.
The Two Threading Laws of Android
- Never block the main thread. Network, disk I/O, and CPU-heavy work must run on a background thread.
- Never touch a View from a background thread. Only the main thread may update UI widgets. Violating this throws a
CalledFromWrongThreadException.
Everything else in this lesson is a consequence of these two rules.
ExecutorService — Running Work in the Background
ExecutorService is a standard Java concurrency interface in java.util.concurrent. It manages a pool of worker threads. You submit a Runnable or Callable, and the executor runs it on one of its threads. When you use it in Android you avoid creating raw new Thread() objects on every operation, which is wasteful and hard to control.
The factory class Executors provides several ready-made pools:
Executors.newSingleThreadExecutor()— one background thread, tasks run in order. Great for sequential DB writes.Executors.newFixedThreadPool(n)— exactly n threads, all running in parallel. Use this when you have multiple independent tasks.Executors.newCachedThreadPool()— creates threads on demand, recycles idle ones. Fine for short-lived bursty work but risky if you submit too many tasks.
Posting Results Back to the Main Thread
After background work finishes you need to update the UI. You do this with a Handler tied to the main thread's Looper. Calling handler.post(runnable) queues that runnable to run on the main thread's message loop — safely.
executor.shutdown() in onDestroy(). If you skip this, background threads keep running after the Activity is gone — wasting memory and potentially crashing when they try to access destroyed Views.
Sharing an Executor Across the App
Creating a new ExecutorService per Activity is fine for simple cases, but larger apps should share a single application-level executor to limit the total number of threads. A common pattern is to store it in your Application subclass:
Usage from any Activity or Fragment:
Cancellation and the Activity Lifecycle
A common bug: the user rotates the screen, the Activity is destroyed and recreated, but the background task still holds a reference to the old Activity's Views. When the task posts back to the main thread it touches destroyed Views — crash or silent data loss.
The safest approach when using raw ExecutorService is to check whether the Activity is still alive before touching the UI:
this (the Activity) inside a lambda that runs for several seconds prevents garbage collection of the entire Activity graph — its Views, Drawables, and all retained data. Prefer passing only the data your UI needs, not the Activity object itself.
Using Future for Results with Return Values
When you need a return value from background work, use executor.submit(callable) which gives you a Future<T>. However, calling future.get() blocks the calling thread — so never call it on the main thread. A better pattern wraps the result delivery in the task itself using mainHandler.post(), as shown above.
For scenarios where multiple background tasks feed into one UI update, CountDownLatch or CompletableFuture (API 24+) are powerful tools, but they follow the same contract: do the blocking wait off the main thread, post only the final result back.
How Room and Retrofit Handle This Automatically
The good news: the libraries you will use in this tutorial — Room (lesson 3) and Retrofit (lesson 6) — are designed to keep background threading out of your sight. Room enforces that queries never run on the main thread (it throws if you try), and its LiveData return types post results to the main thread automatically. Retrofit's enqueue() method dispatches on a background thread and delivers the callback on the main thread.
Understanding raw ExecutorService still matters because it is the foundation those libraries build on, and you will always encounter scenarios — a one-off task, a legacy codebase, or a simple file operation — where the framework does not handle threading for you.
Summary
The Android threading model has two inviolable rules: never block the main thread, and never update Views from a background thread. ExecutorService from java.util.concurrent provides a clean, production-safe way to run work off the main thread. Post UI updates back with a Handler(Looper.getMainLooper()). Share executors at the application level with a singleton like AppExecutors, and always shut them down when their owning component is destroyed. Higher-level libraries like Room and Retrofit automate this pattern — but knowing it yourself makes you a far more effective Android developer.