Android Data, Networking & APIs

Background Work & Threading

18 min Lesson 4 of 12

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.

Why not AsyncTask? 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

  1. Never block the main thread. Network, disk I/O, and CPU-heavy work must run on a background thread.
  2. 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.

import android.os.Handler; import android.os.Looper; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; import android.os.Bundle; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class MainActivity extends AppCompatActivity { // One background thread, shared for the lifetime of this Activity private final ExecutorService executor = Executors.newSingleThreadExecutor(); // Handler bound to the main thread — safe to call from any thread private final Handler mainHandler = new Handler(Looper.getMainLooper()); private TextView resultText; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); resultText = findViewById(R.id.resultText); fetchData(); } private void fetchData() { executor.execute(() -> { // ---- runs on a background thread ---- String result = doSlowNetworkCall(); // blocking I/O, safe here // ---- post result back to the main thread ---- mainHandler.post(() -> { resultText.setText(result); // UI update, safe here }); }); } private String doSlowNetworkCall() { // Simulate 2 seconds of network latency try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return "Hello from the server!"; } @Override protected void onDestroy() { super.onDestroy(); executor.shutdown(); // release thread resources when Activity is gone } }
Always shut down your executor. Call 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:

// AppExecutors.java — a simple singleton holding two pools public class AppExecutors { private static AppExecutors instance; private final ExecutorService diskIO; // serial, for Room / SQLite private final ExecutorService networkIO; // 3 threads, for HTTP calls private final Handler mainThread; private AppExecutors() { diskIO = Executors.newSingleThreadExecutor(); networkIO = Executors.newFixedThreadPool(3); mainThread = new Handler(Looper.getMainLooper()); } public static synchronized AppExecutors getInstance() { if (instance == null) instance = new AppExecutors(); return instance; } public ExecutorService diskIO() { return diskIO; } public ExecutorService networkIO() { return networkIO; } public Handler mainThread() { return mainThread; } }

Usage from any Activity or Fragment:

AppExecutors ex = AppExecutors.getInstance(); ex.networkIO().execute(() -> { String json = fetchFromApi("https://api.example.com/users"); ex.mainThread().post(() -> { // update UI with json }); });

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:

executor.execute(() -> { String result = doSlowWork(); mainHandler.post(() -> { if (!isDestroyed() && !isFinishing()) { // Activity still alive? resultText.setText(result); } }); });
Do not capture Activity context in long-running lambdas. Holding a reference to 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.