Android SharedPreferences Tutorial and Example

Handling concurrency with executors and thread pools

Android applications spend much of their life waiting: for an API response, a database read, a file write or a batch of images to decode. If that work runs on the main thread, scrolling stutters and taps stop responding. Offloading it keeps the interface responsive, but creating an uncontrolled thread for every job creates its own problems.

Executors and thread pools provide a managed way to run background work. They control how many workers exist, how tasks are queued and when processing should stop. On Android, that matters because screen changes, connectivity changes and bursts of requests can arrive at once. An app serving ANZ payment or supermarket catalogue requests must keep those jobs responsive without creating unlimited threads.

Keeping the interface responsive

Android’s main thread draws the interface and dispatches input. A slow database query there can freeze an Activity, while network access may raise NetworkOnMainThreadException. StrictMode can reveal similar disk and network violations during development. Background execution solves the symptom; good task design solves the underlying delay.

Executors separate work into three stages: accepting a task, running it on a worker and publishing the result. The accepting stage is often called the main thread, but it should do little more than capture the request. Workers perform the expensive work, and the result returns to the main thread for an interface update.

For commuters around Sydney and Melbourne, connectivity may disappear and return several times during the day. Each reconnection can trigger a sync, so repeated requests should share one pool and check whether fresh data is already available. This prevents several background callbacks from competing with the interface at once.

Selecting an executor for the job

The Executors factory methods cover common cases. newSingleThreadExecutor() runs tasks one at a time and preserves their order. newFixedThreadPool() keeps a chosen number of workers active. newCachedThreadPool() creates workers as needed and reuses idle ones, although its thread creation needs careful consideration.

Use a single-thread executor when order matters, such as serialising writes to a shared SQLite database. A fixed pool suits independent catalogue refreshes for a Woolworths or Coles app, image processing and parallel validation. A cached pool can accommodate short tasks with unpredictable durations, but it is a poor default for heavy work on a phone.

A fixed pool uses an unbounded queue. For bursty traffic, ThreadPoolExecutor lets you cap the queue with an ArrayBlockingQueue and choose a rejection policy. Threads still share one process, so parallel execution does not make mutable shared state safe.

ScheduledExecutorService can delay work, but Australia’s three main time zones matter when deciding exactly when a task should run. Store the intended instant in UTC and convert it for display rather than assuming every device shares one local zone.

Building a reusable executor service

A long-lived ExecutorService is usually preferable to creating a pool for every button press. Keep the reference with a screen’s ViewModel or a repository, submit work to it and shut it down when its owner is cleared. This reduces thread churn and makes task submission consistent.

class ProductRepository {
    private val executor = Executors.newFixedThreadPool(4)
    private val mainHandler = Handler(Looper.getMainLooper())

    fun refreshCatalogue(onLoaded: (List<Product>) -> Unit) {
        executor.execute {
            val products = catalogueApi.fetchProducts()
            mainHandler.post {
                onLoaded(products)
            }
        }
    }

    fun close() {
        executor.shutdown()
    }
}

Replace the API and model names with your project’s equivalents. The callback runs on the main thread, so it can update the interface, but it should check whether the screen still exists before touching a view.

A background task may also communicate with a router on the local network. Prepare the hardware correctly first; this Edimax access point setup guide covers access-point mode for an Edimax device. Once the device is reachable, the request itself can run in the pool without blocking the user.

Managing lifecycle and cancellation

Pool size does not replace lifecycle management. An Activity can be destroyed while its task is still running, especially during rotation or when Android reclaims memory under pressure. A callback that captures that Activity keeps it alive until the task ends.

When an Activity owns the executor, release it in the matching lifecycle callback:

override fun onDestroy() {
    executor.shutdownNow()
    super.onDestroy()
}

shutdownNow() attempts to interrupt active workers and stops accepting queued tasks. Network and database calls may not respond to interruption immediately, so still check lifecycle state before updating a view. A ViewModel owner survives configuration changes and is often a better place for work that must finish across rotation.

If work must stop when the user leaves, retain the Future returned by submit() and call cancel(true). Handle CancellationException separately from genuine failures, and do not present a cancelled sync as an error.

Delivering results and handling failure

Exceptions on a worker do not automatically reach the code that submitted the task. Code running directly inside execute() can escape the worker, while submit() returns a Future that captures the exception. Wrap expected I/O failures inside the task, record the failure and publish a clear interface state on the main thread.

Inside an Activity, create a main-thread handler and check that the screen remains usable before updating it:

mainHandler.post {
    if (!isFinishing && !isDestroyed) {
        showSyncComplete(result)
    }
}

Do not swallow every exception and report success. Logging the cause helps distinguish a mobile dead zone in regional Australia from a server error, while a retry should use backoff rather than immediately repeating a failing request. WorkManager is usually the better choice for work that must survive process death.

Results should be immutable snapshots or safely collected values. Sharing a mutable list between the worker and the interface creates a race even when the update itself runs on the main thread. SharedPreferences can store a flag, but it is not a substitute for a task queue or a locking mechanism.

Comparing executor choices

Pools differ mainly in worker count, queue behaviour and ordering. Choose based on task independence, expected burst size and how long tasks may run. An unbounded queue is convenient but can delay urgent work behind a long backlog.

These choices suit different workloads:

Executor Useful for Main limitation
newSingleThreadExecutor() Ordered work and serialised database writes One slow task blocks everything behind it
newFixedThreadPool(n) Independent refreshes and image processing Its unbounded queue can grow substantially
newCachedThreadPool() Short tasks with unpredictable durations May create many threads under heavy load
ThreadPoolExecutor Custom worker counts, queue limits and rejection rules Requires deliberate configuration and error handling

For most Android applications, a modest fixed pool is the easiest option to understand. Move to a custom ThreadPoolExecutor when queue limits, rejection handling or separate priority lanes are required. Keep ownership explicit, publish results safely and verify the behaviour on consumer devices under slow and offline conditions.