Android System Design Interview Questions
Design a file downloader library.
Tier: CommonDifficulty: Hard
A priority queue in front of a small pool of resumable transfers. Each one writes to a temporary file, persists its byte offset as it goes, and renames into place only once the file is whole. The interesting part is not fetching bytes, it is everything around it. Pause, resume, cancel, and progress reporting for a transfer that is big enough and slow enough to outlive the screen that started it. If it were a quick network call it would not be a design question.
What I'd clarify first
- Are downloads large enough that pause and resume actually matter, or is this for small files where restarting on failure is cheap enough.
- Does a download need to survive the app being backgrounded or killed mid transfer, which pushes the design toward a foreground service and persisted state.
- Do we need parallel downloads with priority ordering, or is one download at a time acceptable.
Core components
- A request builder,
DownloadRequest.Builder(url).setDirectory(dir).setFileName(name).build(), that produces an immutable request object rather than a pile of loose parameters. - A priority queue with a small bounded pool, two or three concurrent transfers with the rest queued by priority. On a phone the constraint is bandwidth and the server's per host limit, not CPU, so more workers only split the same bytes per second and get you throttled.
- A transport with timeouts and cancellation. In practice that is OkHttp, which gives you the connection pool, connect and read timeouts, and
Call.cancel()for free. The sample usesHttpURLConnectionwith the timeouts set explicitly so nothing in the prose is implemented nowhere. - A persisted download state table, in Room, tracking each download's URL, destination path, total size, and bytes written so far. This is what makes resume possible after the app process itself is killed, not just after a network drop while the app stays alive.
- A foreground service with a progress notification for any download expected to run more than a few seconds. This is not optional on modern Android. A long running background transfer without a foreground service is exactly what the OS kills. The
Downloadershown below is what that service, or aWorkManagerworker, drives.
How a download flows
The client fires an HTTP request with a Range header starting at the last known byte offset from the state table, zero for a fresh download. As bytes stream in they're written to a temp file. The progress callback fires with bytes so far versus total, throttled to a sensible interval rather than on every packet. On pause or a connection drop the current byte offset is persisted, so resume just means firing the same request again with an updated Range start. On completion the temp file is verified, a checksum if the server provides one and a size match otherwise, and only then renamed into its final destination. A failure partway through never leaves a corrupted file where a caller would treat it as complete.
Two details in the loop are where candidates get caught. A server that ignores Range answers 200 with the whole body, and the temp file has to be truncated before writing. Otherwise a shorter fresh body leaves the stale tail in place and the corrupt file passes the length check. A chunked response has no content length at all, so unknown has to stay unknown rather than turn into a false truncation error. Cancel is its own outcome, not a failure, so a UI can show paused instead of retry.
Java
com.androidinterview.filedownloader.DownloadRequest.java
package com.androidinterview.filedownloader;
import java.io.File;
// Built once and never mutated, so a queued download cannot change under the
// worker that is already running it. The id is what survives process death,
// because the resume offset has to be filed under something stable.
public record DownloadRequest(String id, String url, File directory, String fileName, int priority) {
public File temporaryFile() {
return new File(directory, fileName + ".part");
}
public File finishedFile() {
return new File(directory, fileName);
}
}
com.androidinterview.filedownloader.DownloadTask.java
package com.androidinterview.filedownloader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.URI;
// One download, resumable, cancellable, and reporting progress on an interval.
// Everything in this class exists because the file is large enough that
// starting over is expensive. On Android this runs inside a foreground
// service or a WorkManager worker, which is what keeps the process alive for
// a transfer that outlives the screen that started it.
public final class DownloadTask implements Runnable, Comparable<DownloadTask> {
public interface Listener {
// totalBytes is -1 when the server did not say, a chunked response.
void onProgress(long bytesWritten, long totalBytes);
void onComplete(File file);
// Its own outcome, not a failure. A UI shows paused for this and
// retry for onFailed, and it cannot tell them apart from an exception.
void onCancelled(long bytesWritten);
void onFailed(Exception cause);
}
private static final int CHUNK = 8 * 1024;
private static final long REPORT_INTERVAL_MILLIS = 200;
// Generous on read, because a slow transfer that is still moving is not a
// failure. In a real build these live on the OkHttp client and Call.cancel
// replaces the flag below.
private static final int CONNECT_TIMEOUT_MILLIS = 15_000;
private static final int READ_TIMEOUT_MILLIS = 60_000;
private final DownloadRequest request;
private final ProgressStore store;
private final Listener listener;
private final Runnable onTerminated;
private volatile boolean cancelled;
public DownloadTask(DownloadRequest request, ProgressStore store, Listener listener, Runnable onTerminated) {
this.request = request;
this.store = store;
this.listener = listener;
this.onTerminated = onTerminated;
}
public void cancel() {
cancelled = true;
}
@Override
public void run() {
try {
if (cancelled) {
// Cancelled while still queued. Nothing was opened, so the
// resume point is whatever the last run persisted.
listener.onCancelled(resumeOffset());
return;
}
long written = transfer(resumeOffset());
if (cancelled) {
// Pause and cancel are one code path. The offset is persisted
// either way, so resuming is the same request with a new Range
// header and no special case.
listener.onCancelled(written);
return;
}
File finished = request.finishedFile();
// Rename last. A file appears at its real path only once it is
// whole, so a process killed mid transfer can never leave
// something a later reader would treat as a complete download.
if (!request.temporaryFile().renameTo(finished)) {
throw new IOException("Could not commit " + request.temporaryFile());
}
store.clear(request.id());
listener.onProgress(written, written);
listener.onComplete(finished);
} catch (Exception failure) {
listener.onFailed(failure);
} finally {
onTerminated.run();
}
}
// The persisted offset is written on a throttle, so after a kill the part
// file is usually a little longer than the saved offset, which is safe.
// The other way round would mean seeking past the end and leaving a hole
// of zeros in the middle of the file, so the file's length is the ceiling.
private long resumeOffset() {
if (!request.temporaryFile().exists()) {
return 0;
}
return Math.min(store.bytesWritten(request.id()), request.temporaryFile().length());
}
private long transfer(long resumeFrom) throws IOException {
HttpURLConnection connection = (HttpURLConnection) URI.create(request.url()).toURL().openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS);
connection.setReadTimeout(READ_TIMEOUT_MILLIS);
if (resumeFrom > 0) {
connection.setRequestProperty("Range", "bytes=" + resumeFrom + "-");
}
connection.connect();
// A server that does not honour Range answers 200 with the whole file
// rather than 206 with the tail. Any bytes already on disk are then
// meaningless, so the offset resets and the download starts over. This
// fallback is not optional, plenty of servers ignore Range.
long start = connection.getResponseCode() == HttpURLConnection.HTTP_PARTIAL ? resumeFrom : 0;
// A chunked response has no Content-Length and reports -1. Adding an
// offset to -1 would make the total smaller than the offset, so
// unknown stays unknown and the length check below is skipped.
long length = connection.getContentLengthLong();
long total = length < 0 ? -1 : start + length;
long written = start;
long lastReport = 0;
try (InputStream source = connection.getInputStream();
RandomAccessFile sink = new RandomAccessFile(request.temporaryFile(), "rw")) {
sink.seek(start);
// Truncate to the start point. On the 200 fallback that is zero,
// and without it a fresh body shorter than the stale bytes already
// on disk would leave the old tail in place, pass the length check
// and be renamed into place corrupt.
sink.setLength(start);
if (start == 0) {
store.saveProgress(request.id(), 0);
}
byte[] buffer = new byte[CHUNK];
for (int read; (read = source.read(buffer)) != -1; ) {
if (cancelled) {
break;
}
sink.write(buffer, 0, read);
written += read;
long now = System.currentTimeMillis();
if (now - lastReport >= REPORT_INTERVAL_MILLIS) {
// Both of these are throttled together. A callback per
// chunk floods the main thread with updates no eye can
// resolve, and a database write per chunk costs more I O
// than the download itself.
listener.onProgress(written, total);
store.saveProgress(request.id(), written);
lastReport = now;
}
}
} finally {
// Whatever ended the loop, cancel, failure or completion, the
// resume point is exact rather than up to 200 milliseconds stale.
store.saveProgress(request.id(), written);
}
if (cancelled) {
return written;
}
// The length check is the cheap integrity check. When the server sends
// a digest header, verify that instead, a truncated file that happens
// to have the right length is still corrupt.
if (total >= 0 && written != total) {
throw new IOException("Truncated at " + written + " of " + total);
}
return written;
}
// Higher priority first, so a download the user is watching preempts a
// background prefetch instead of queueing behind it.
@Override
public int compareTo(DownloadTask other) {
return Integer.compare(other.request.priority(), request.priority());
}
}
com.androidinterview.filedownloader.Downloader.java
package com.androidinterview.filedownloader;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
// The queue in front of the tasks. Downloads are I O bound, so the pool can
// run more of them than there are cores, but it still needs a cap. Dozens of
// parallel connections to one host get you throttled rather than served
// faster, and on a thin connection they only split the same bandwidth.
public final class Downloader {
private final ThreadPoolExecutor pool;
private final ProgressStore store;
private final Map<String, DownloadTask> running = new ConcurrentHashMap<>();
public Downloader(int concurrency, ProgressStore store) {
this.store = store;
// The tasks are queued directly rather than submitted, because submit
// wraps a runnable in a FutureTask and the priority queue would then
// be ordering wrappers that cannot compare themselves.
this.pool = new ThreadPoolExecutor(
concurrency, concurrency, 0, TimeUnit.MILLISECONDS, new PriorityBlockingQueue<>());
}
public void enqueue(DownloadRequest request, DownloadTask.Listener listener) {
DownloadTask task =
new DownloadTask(request, store, listener, () -> running.remove(request.id()));
running.put(request.id(), task);
pool.execute(task);
}
// Cancelling a queued download and cancelling a running one are the same
// call. The offset is already persisted, so enqueueing the same id later
// resumes rather than restarts. A task pulled out of the queue before it
// ran is run inline, so the listener still hears cancelled. It opens no
// connection, because the flag is checked first.
public void cancel(String id) {
DownloadTask task = running.remove(id);
if (task != null) {
task.cancel();
if (pool.remove(task)) {
task.run();
}
}
}
}
com.androidinterview.filedownloader.ProgressStore.java
package com.androidinterview.filedownloader;
// Where the resume offset lives, a Room table in a real app. It is on disk
// rather than in memory because the interesting case is not a dropped
// connection, it is the process being killed while a two hundred megabyte
// download was half done.
public interface ProgressStore {
long bytesWritten(String id);
void saveProgress(String id, long bytes);
void clear(String id);
}
Kotlin
com.androidinterview.filedownloader.DownloadRequest.kt
package com.androidinterview.filedownloader
import java.io.File
// Built once and never mutated, so a queued download cannot change under the
// worker already running it. The id is what survives process death, because
// the resume offset has to be filed under something stable.
data class DownloadRequest(
val id: String,
val url: String,
val directory: File,
val fileName: String,
val priority: Int = 0,
) {
val temporaryFile get() = File(directory, "$fileName.part")
val finishedFile get() = File(directory, fileName)
}
// Where the resume offset lives, a Room table in a real app. It is on disk
// rather than in memory because the interesting case is not a dropped
// connection, it is the process being killed while a large download was half
// done.
interface ProgressStore {
fun bytesWritten(id: String): Long
fun saveProgress(id: String, bytes: Long)
fun clear(id: String)
}
sealed interface DownloadEvent {
// totalBytes is -1 when the server did not say, a chunked response.
data class Progress(val bytesWritten: Long, val totalBytes: Long) : DownloadEvent
data class Complete(val file: File) : DownloadEvent
// Its own outcome, not a failure. A UI shows paused for this and retry
// for Failed, and it cannot tell them apart from an exception.
data class Cancelled(val bytesWritten: Long) : DownloadEvent
data class Failed(val cause: Exception) : DownloadEvent
}
com.androidinterview.filedownloader.DownloadTask.kt
package com.androidinterview.filedownloader
import java.io.IOException
import java.io.RandomAccessFile
import java.net.HttpURLConnection
import java.net.URI
private const val CHUNK = 8 * 1024
private const val REPORT_INTERVAL_MILLIS = 200L
// Generous on read, because a slow transfer that is still moving is not a
// failure. In a real build these live on the OkHttp client and Call.cancel
// replaces the flag below.
private const val CONNECT_TIMEOUT_MILLIS = 15_000
private const val READ_TIMEOUT_MILLIS = 60_000
// One download, resumable, cancellable, and reporting progress on an interval.
// Everything here exists because the file is large enough that starting over
// is expensive. On Android this runs inside a foreground service or a
// WorkManager worker, which is what keeps the process alive for a transfer
// that outlives the screen that started it.
class DownloadTask(
private val request: DownloadRequest,
private val store: ProgressStore,
private val onEvent: (DownloadEvent) -> Unit,
private val onTerminated: () -> Unit = {},
) : Runnable, Comparable<DownloadTask> {
@Volatile
private var cancelled = false
fun cancel() {
cancelled = true
}
override fun run() {
try {
if (cancelled) {
// Cancelled while still queued. Nothing was opened, so the
// resume point is whatever the last run persisted.
onEvent(DownloadEvent.Cancelled(resumeOffset()))
return
}
val written = transfer(resumeOffset())
if (cancelled) {
// Pause and cancel are one code path. The offset is persisted
// either way, so resuming is the same request with a new Range
// header and no special case.
onEvent(DownloadEvent.Cancelled(written))
return
}
// Rename last. A file appears at its real path only once it is
// whole, so a process killed mid transfer can never leave
// something a later reader treats as a finished download.
check(request.temporaryFile.renameTo(request.finishedFile)) { "Could not commit ${request.temporaryFile}" }
store.clear(request.id)
onEvent(DownloadEvent.Progress(written, written))
onEvent(DownloadEvent.Complete(request.finishedFile))
} catch (failure: Exception) {
onEvent(DownloadEvent.Failed(failure))
} finally {
onTerminated()
}
}
// The persisted offset is written on a throttle, so after a kill the part
// file is usually a little longer than the saved offset, which is safe.
// The other way round would mean seeking past the end and leaving a hole
// of zeros in the middle of the file, so the file's length is the ceiling.
private fun resumeOffset(): Long =
if (request.temporaryFile.exists()) minOf(store.bytesWritten(request.id), request.temporaryFile.length()) else 0
private fun transfer(resumeFrom: Long): Long {
val connection = URI.create(request.url).toURL().openConnection() as HttpURLConnection
connection.connectTimeout = CONNECT_TIMEOUT_MILLIS
connection.readTimeout = READ_TIMEOUT_MILLIS
if (resumeFrom > 0) connection.setRequestProperty("Range", "bytes=$resumeFrom-")
connection.connect()
// A server that does not honour Range answers 200 with the whole file
// rather than 206 with the tail. Bytes already on disk are then
// meaningless, so the offset resets and the download starts over. This
// fallback is not optional, plenty of servers ignore Range.
val start = if (connection.responseCode == HttpURLConnection.HTTP_PARTIAL) resumeFrom else 0L
// A chunked response has no Content-Length and reports -1. Adding an
// offset to -1 would make the total smaller than the offset, so
// unknown stays unknown and the length check below is skipped.
val total = connection.contentLengthLong.let { if (it < 0) -1L else start + it }
var written = start
var lastReport = 0L
try {
connection.inputStream.use { source ->
RandomAccessFile(request.temporaryFile, "rw").use { sink ->
sink.seek(start)
// Truncate to the start point. On the 200 fallback that is
// zero, and without it a fresh body shorter than the stale
// bytes already on disk would leave the old tail in place,
// pass the length check and be renamed into place corrupt.
sink.setLength(start)
if (start == 0L) store.saveProgress(request.id, 0)
val buffer = ByteArray(CHUNK)
while (!cancelled) {
val read = source.read(buffer)
if (read < 0) break
sink.write(buffer, 0, read)
written += read
val now = System.currentTimeMillis()
if (now - lastReport >= REPORT_INTERVAL_MILLIS) {
// Throttled together. A callback per chunk floods
// the main thread with updates no eye can resolve,
// and a database write per chunk costs more than
// the download itself.
onEvent(DownloadEvent.Progress(written, total))
store.saveProgress(request.id, written)
lastReport = now
}
}
}
}
} finally {
// Whatever ended the loop, cancel, failure or completion, the
// resume point is exact rather than up to 200 milliseconds stale.
store.saveProgress(request.id, written)
}
if (cancelled) return written
// The length check is the cheap integrity check. When the server sends
// a digest header, verify that instead, a truncated file that happens
// to have the right length is still corrupt.
if (total >= 0 && written != total) throw IOException("Truncated at $written of $total")
return written
}
// Higher priority first, so a download the user is watching preempts a
// background prefetch instead of queueing behind it.
override fun compareTo(other: DownloadTask) = other.request.priority.compareTo(request.priority)
}
com.androidinterview.filedownloader.Downloader.kt
package com.androidinterview.filedownloader
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit
// The queue in front of the tasks. Downloads are I O bound, so the pool can
// run more of them than there are cores, but it still needs a cap. Dozens of
// parallel connections to one host get you throttled rather than served
// faster, and on a thin connection they only split the same bandwidth.
class Downloader(concurrency: Int, private val store: ProgressStore) {
// Tasks are queued rather than submitted, because submit wraps a runnable
// in a FutureTask and the priority queue would then be ordering wrappers
// that cannot compare themselves.
private val pool = ThreadPoolExecutor(
concurrency, concurrency, 0, TimeUnit.MILLISECONDS, PriorityBlockingQueue(),
)
private val running = ConcurrentHashMap<String, DownloadTask>()
fun enqueue(request: DownloadRequest, onEvent: (DownloadEvent) -> Unit) {
val task = DownloadTask(request, store, onEvent) { running.remove(request.id) }
running[request.id] = task
pool.execute(task)
}
// Cancelling a queued download and cancelling a running one are the same
// call. The offset is already persisted, so enqueueing the same id later
// resumes rather than restarts. A task pulled out of the queue before it
// ran is run inline, so the listener still hears cancelled. It opens no
// connection, because the flag is checked first.
fun cancel(id: String) {
val task = running.remove(id) ?: return
task.cancel()
if (pool.remove(task)) task.run()
}
}
Tradeoffs I'd call out
- Range based resume vs restart from zero. Resume is what saves the user's data and time on a large file over a flaky connection, but it depends on the server honouring
Rangerequests. Not every server does, so the library needs a fallback to a full restart when a206 Partial Contentresponse doesn't come back. - Parallel downloads vs total throughput. Running several downloads at once feels faster for the user managing a queue, but on a constrained connection they compete for the same bandwidth. Past a small number of concurrent transfers you're adding overhead without moving more bytes per second, which is why the pool is two or three.
- Persisting progress on every chunk vs batching writes. Writing the byte offset to Room after every single chunk guarantees an accurate resume point but adds real I/O overhead on top of the download itself. Persisting on an interval trades a slightly coarser resume point for meaningfully less write overhead, and the exact offset is written once more whenever the loop ends.
What breaks at scale, offline, and on a poor connection
At scale, managing a large queue of downloads means priority has to be explicit. A user initiated download the user is actively watching should preempt a background prefetch, not wait behind it in FIFO order. Offline, queued downloads should pause cleanly rather than fail and get dropped, and resume once connectivity returns. That is exactly the scenario WorkManager backed retry is built for if the library is layered on top of it rather than managing its own reconnection logic. On a poor connection, timeouts need to be generous enough not to abandon a slow but progressing transfer, paired with exponential backoff on actual failures. A flaky network then degrades to slower downloads rather than a stream of failed, restarted from zero ones.
Watch