Android System Design Interview Questions
Design the components and overall architecture for a Search feature in an Android application.
Tier: EssentialDifficulty: HardAsked at: spotify, paytm
Design search inside an Android app. A user types a query, sees suggestions or matching items and opens a result. The screen should remain responsive and make loading, empty results and failures understandable.
The problem
Start with a remote catalog, recent searches and paginated results. Decide when typing should trigger a request, what happens to an earlier request and whether anything can be shown offline.
For example, Ana types ca and then camera. The server responds to camera first and ca later. The screen must continue showing camera results. The older response must not replace them. Clearing the query should also clear or replace results according to the agreed empty search behaviour.
Cover screen state, request coordination, result storage and the search API. Ranking and typo correction can remain backend responsibilities. The client needs to associate every result with the query that produced it.
Starting the design
Search feels like a text field and a list, but the design problem is really about not sending a network request on every keystroke while still feeling instant, and about giving the user something useful before they've typed anything at all.
What I'd clarify first
- Is this searching a fixed local dataset, a remote catalog, or both, local results appearing instantly while remote results stream in behind them.
- Does it need query suggestions and recent search history, or just results for a submitted query.
- What's the expected result volume per query, a handful of matches or something that needs pagination.
- Is typo tolerance the server's job, so that "adiddas" still returns Adidas, because a client cannot spell correct against a catalogue it does not hold.
Core components
- A debounced input pipeline. Keystrokes go into a
Flow, debounced by a couple hundred milliseconds and filtered to skip near duplicate or empty queries, so a user typing "shoes" fires one network request when they pause, not five as each letter lands. - A local first result path. Before any network round trip, check a local Room backed index, recent searches, cached results, or a locally synced subset of the catalog, and show whatever matches immediately, network results merge in once they arrive rather than the user staring at a blank screen for a round trip.
- A ranked results API,
GET /search?q=&page=&pageSize=, returning paginated, server ranked results, ranking and relevance logic belongs on the backend, not recomputed or reordered on the client. - A suggestions endpoint, separate from full search,
GET /search/suggestions?q=, a fast, lightweight autocomplete call distinct from the heavier full search request, since suggestions need to return in well under debounce latency to feel responsive while typing. - A real zero result state. A query that matches nothing is a designed screen, not a blank list. It shows the query back to the user, whatever the server offered as a spelling correction or a did you mean, and a couple of popular or recent queries to get them moving again.
How a query flows
Each keystroke updates the query Flow, and debounce collapses rapid typing into one emission once the user pauses. That emission then drives four things.
- A suggestions call for the dropdown beneath the field, on the shorter gate.
- The full search call, once the user submits or the longer debounce elapses.
- Paging 3 for large results, with a configured maximum size when old pages should be dropped to bound memory.
- A write to recent search history when the user submits or selects a result, rather than on each keystroke.
The code
Normalize queries and switch immediately with flatMapLatest, including empty queries. Put the debounce delay inside the new request flow so a fresh keystroke cancels the previous work immediately. The full Android example below shows the short query reset, cancellation and lifecycle subscription together.
Debounce is written in the tree as a decision rather than a timer, so the rule is testable without a clock and the two gates can differ. Suggestions fire sooner and from one character, full search waits longer and asks for two, because a suggestion that arrives after the user stopped typing has already failed. The gate also resets when the field is cleared, or searching shoes, clearing, and typing shoes again is refused forever.
The request sequence is the file worth reading twice. It is the out of order response bug, a user types shoe then shoes, the slower response for shoe lands second, and the screen paints results for a query the user has moved past. Cancellation is the first half of the fix and stamping every request is the half that holds, because a cancellation can lose the race. Local results merge under the remote ones with the server ranking kept intact, never interleaved by a score the client invented. The Android section connects this ordering policy to ViewModel state and explains where Paging and the API sit.
Java
com.androidinterview.search.query.QueryGate.java
package com.androidinterview.search.query;
// Debounce, as a decision rather than a timer, so the rule is testable without
// a clock. Flow.debounce sits on top of this in the app, and this is what it
// is actually deciding.
//
// Two filters matter as much as the delay. A query that trims to the same text
// as the last one fires nothing, which covers a user adding and deleting a
// space, and anything under the minimum length fires nothing, because a single
// letter matches most of the catalogue and costs a full search to find out.
//
// Clearing the field resets the last accepted query. Without that, searching
// shoes, clearing, and typing shoes again is refused forever, and the user is
// left with an empty screen and a field that will not search.
public final class QueryGate {
private final long debounceMillis;
private final int minLength;
private String lastAccepted = "";
public QueryGate(long debounceMillis, int minLength) {
this.debounceMillis = debounceMillis;
this.minLength = minLength;
}
public boolean accept(String raw, long lastKeystrokeAt, long nowMillis) {
String query = raw.trim();
if (query.isEmpty()) {
lastAccepted = "";
return false;
}
if (query.length() < minLength || query.equals(lastAccepted)) return false;
if (nowMillis - lastKeystrokeAt < debounceMillis) return false;
lastAccepted = query;
return true;
}
// Suggestions are a different call with a different budget, so they get a
// different gate. A suggestion that arrives after the user finished typing
// has failed, which is why it fires sooner and asks for less.
public static QueryGate forSuggestions() {
return new QueryGate(120, 1);
}
public static QueryGate forFullSearch() {
return new QueryGate(350, 2);
}
}
package com.androidinterview.search.query;
public final class QueryGate {
private final long debounceMillis;
private final int minLength;
private String lastAccepted = "";
public QueryGate(long debounceMillis, int minLength) {
this.debounceMillis = debounceMillis;
this.minLength = minLength;
}
public boolean accept(String raw, long lastKeystrokeAt, long nowMillis) {
String query = raw.trim();
if (query.isEmpty()) {
lastAccepted = "";
return false;
}
if (query.length() < minLength || query.equals(lastAccepted)) return false;
if (nowMillis - lastKeystrokeAt < debounceMillis) return false;
lastAccepted = query;
return true;
}
public static QueryGate forSuggestions() {
return new QueryGate(120, 1);
}
public static QueryGate forFullSearch() {
return new QueryGate(350, 2);
}
}
com.androidinterview.search.query.RequestSequence.java
package com.androidinterview.search.query;
// Latest wins, and this is the bug that only shows up on a bad connection.
//
// A user types shoe, then shoes. Two requests are in flight. The response for
// shoe comes back second because that connection happened to be slower, and
// the screen now shows results for a query the user has already moved past.
// Cancelling the in flight call is the first half of the fix. Stamping every
// request and rendering only the newest is the half that actually holds,
// because a cancellation can always lose the race.
//
// Issued and read on the main thread, which is why there is no lock here. In a
// Flow this file disappears entirely, because flatMapLatest cancels the
// previous collection for you.
public final class RequestSequence {
private long issued;
public long next() {
return ++issued;
}
public boolean isCurrent(long generation) {
return generation == issued;
}
}
package com.androidinterview.search.query;
public final class RequestSequence {
private long issued;
public long next() {
return ++issued;
}
public boolean isCurrent(long generation) {
return generation == issued;
}
}
com.androidinterview.search.results.RecentSearches.java
package com.androidinterview.search.results;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
// What the screen shows before a single letter is typed, which is the half of
// a search feature that is usually left until last. A Room table behind this
// in a real app, bounded, newest first, with a repeat moving to the top rather
// than appearing twice.
public final class RecentSearches {
private final Deque<String> queries = new ArrayDeque<>();
private final int limit;
public RecentSearches(int limit) {
this.limit = limit;
}
public void record(String query) {
queries.remove(query);
queries.addFirst(query);
if (queries.size() > limit) queries.removeLast();
}
public List<String> all() {
return List.copyOf(queries);
}
}
package com.androidinterview.search.results;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
public final class RecentSearches {
private final Deque<String> queries = new ArrayDeque<>();
private final int limit;
public RecentSearches(int limit) {
this.limit = limit;
}
public void record(String query) {
queries.remove(query);
queries.addFirst(query);
if (queries.size() > limit) queries.removeLast();
}
public List<String> all() {
return List.copyOf(queries);
}
}
com.androidinterview.search.results.ResultMerge.java
package com.androidinterview.search.results;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
// Local first, then the network on top. The local pass comes from a Room table
// of recent results and whatever slice of the catalogue is synced, and it
// paints in a frame instead of a round trip.
//
// The merge rule is the point. Server ranking wins for anything the server
// returned, because relevance is computed there with the data to do it with,
// and the local extras keep their place underneath rather than being
// interleaved by a score the client invented.
public final class ResultMerge {
// local is what the row renders as a cached badge, so the user can tell an
// instant local hit from a server ranked one.
public record Result(String id, String title, boolean local) {
}
private ResultMerge() {
}
public static List<Result> merge(List<Result> local, List<Result> remote) {
List<Result> merged = new ArrayList<>(remote);
Set<String> seen = new HashSet<>();
for (Result result : remote) seen.add(result.id());
for (Result result : local) {
if (seen.add(result.id())) merged.add(result);
}
return merged;
}
}
package com.androidinterview.search.results;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public final class ResultMerge {
public record Result(String id, String title, boolean local) {
}
private ResultMerge() {
}
public static List<Result> merge(List<Result> local, List<Result> remote) {
List<Result> merged = new ArrayList<>(remote);
Set<String> seen = new HashSet<>();
for (Result result : remote) seen.add(result.id());
for (Result result : local) {
if (seen.add(result.id())) merged.add(result);
}
return merged;
}
}
Kotlin
com.androidinterview.search.query.QueryPipeline.kt
package com.androidinterview.search.query
// Debounce, as a decision rather than a timer, so the rule is testable without
// a clock. Flow.debounce sits on top of this in the app, and this is what it
// is actually deciding.
//
// Two filters matter as much as the delay. A query that trims to the same text
// as the last one fires nothing, which covers a user adding and deleting a
// space, and anything under the minimum length fires nothing, because a single
// letter matches most of the catalogue and costs a full search to find out.
//
// Clearing the field resets the last accepted query. Without that, searching
// shoes, clearing, and typing shoes again is refused forever, and the user is
// left with an empty screen and a field that will not search.
class QueryGate(private val debounceMillis: Long, private val minLength: Int) {
private var lastAccepted = ""
fun accept(raw: String, lastKeystrokeAt: Long, nowMillis: Long): Boolean {
val query = raw.trim()
if (query.isEmpty()) {
lastAccepted = ""
return false
}
if (query.length < minLength || query == lastAccepted) return false
if (nowMillis - lastKeystrokeAt < debounceMillis) return false
lastAccepted = query
return true
}
// Suggestions are a different call with a different budget, so they get a
// different gate. A suggestion arriving after the user finished typing has
// failed, which is why it fires sooner and asks for less.
companion object {
fun forSuggestions() = QueryGate(debounceMillis = 120, minLength = 1)
fun forFullSearch() = QueryGate(debounceMillis = 350, minLength = 2)
}
}
// Latest wins, and this is the bug that only shows up on a bad connection. A
// user types shoe, then shoes, and the slower response for shoe lands second
// and paints results for a query already moved past. Cancelling the in flight
// call is the first half of the fix. Stamping every request and rendering only
// the newest is the half that holds, because a cancellation can lose the race.
//
// Issued and read on the main thread, which is why there is no lock here. In a
// Flow this file disappears entirely, because flatMapLatest cancels the
// previous collection for you.
class RequestSequence {
private var issued = 0L
fun next() = ++issued
fun isCurrent(generation: Long) = generation == issued
}
package com.androidinterview.search.query
class QueryGate(private val debounceMillis: Long, private val minLength: Int) {
private var lastAccepted = ""
fun accept(raw: String, lastKeystrokeAt: Long, nowMillis: Long): Boolean {
val query = raw.trim()
if (query.isEmpty()) {
lastAccepted = ""
return false
}
if (query.length < minLength || query == lastAccepted) return false
if (nowMillis - lastKeystrokeAt < debounceMillis) return false
lastAccepted = query
return true
}
companion object {
fun forSuggestions() = QueryGate(debounceMillis = 120, minLength = 1)
fun forFullSearch() = QueryGate(debounceMillis = 350, minLength = 2)
}
}
class RequestSequence {
private var issued = 0L
fun next() = ++issued
fun isCurrent(generation: Long) = generation == issued
}
com.androidinterview.search.results.Results.kt
package com.androidinterview.search.results
// local is what the row renders as a cached badge, so the user can tell an
// instant local hit from a server ranked one.
data class Result(val id: String, val title: String, val local: Boolean)
// Local first, then the network on top. The local pass comes from a Room table
// of recent results and whatever slice of the catalogue is synced, and it
// paints in a frame instead of a round trip.
//
// The merge rule is the point. Server ranking wins for anything the server
// returned, because relevance is computed there with the data to do it with,
// and the local extras keep their place underneath rather than being
// interleaved by a score the client invented.
fun merge(local: List<Result>, remote: List<Result>): List<Result> {
val seen = remote.mapTo(mutableSetOf()) { it.id }
return remote + local.filterNot { it.id in seen }
}
// What the screen shows before a single letter is typed, which is the half of
// a search feature usually left until last. A Room table sits behind this,
// bounded, newest first, with a repeat moving to the top rather than appearing
// twice.
class RecentSearches(private val limit: Int) {
private val queries = ArrayDeque<String>()
fun record(query: String) {
queries.remove(query)
queries.addFirst(query)
if (queries.size > limit) queries.removeLast()
}
fun all(): List<String> = queries.toList()
}
package com.androidinterview.search.results
data class Result(val id: String, val title: String, val local: Boolean)
fun merge(local: List<Result>, remote: List<Result>): List<Result> {
val seen = remote.mapTo(mutableSetOf()) { it.id }
return remote + local.filterNot { it.id in seen }
}
class RecentSearches(private val limit: Int) {
private val queries = ArrayDeque<String>()
fun record(query: String) {
queries.remove(query)
queries.addFirst(query)
if (queries.size > limit) queries.removeLast()
}
fun all(): List<String> = queries.toList()
}
How I'd build this on Android
I'd keep the query and filters in SearchViewModel. SearchRepository combines local results with a remote search API. A DAO stores recent searches and cached results. The cache key includes the query, filters, locale and account, so one search cannot overwrite another search's results.
When the query changes, I'd cancel the old search immediately, then wait briefly before sending the new one. Putting a length filter before flatMapLatest means clearing the text may never cancel the old request. Putting debounce before the switch leaves the old request active during the wait. The delay below belongs inside the new query's cancellable work.
sealed interface SearchState {
data object Empty : SearchState
data class Loading(val query: String) : SearchState
data class Results(val query: String, val rows: List<SearchHit>) : SearchState
data class Failed(val query: String) : SearchState
}
data class SearchHit(val id: String, val title: String)
interface SearchRepository {
fun search(query: String): Flow<List<SearchHit>>
}
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModel(
private val saved: SavedStateHandle,
private val repository: SearchRepository,
) : ViewModel() {
val query = saved.getStateFlow("query", "")
val uiState = query.map { it.trim() }.distinctUntilChanged()
.flatMapLatest { q ->
flow<SearchState> {
if (q.length < 2) {
emit(SearchState.Empty)
} else {
emit(SearchState.Loading(q))
delay(350)
repository.search(q).collect { emit(SearchState.Results(q, it)) }
}
}.catch { cause ->
if (cause is CancellationException) throw cause
emit(SearchState.Failed(q))
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchState.Empty)
fun onQueryChange(value: String) { saved["query"] = value }
}
@Composable
fun SearchRoute(vm: SearchViewModel) {
val query by vm.query.collectAsStateWithLifecycle()
val state by vm.uiState.collectAsStateWithLifecycle()
Column {
TextField(value = query, onValueChange = vm::onQueryChange)
val result = state as? SearchState.Results
// Do not show an old emission while the new query is propagating.
if (result != null && result.query == query.trim()) {
LazyColumn {
items(result.rows, key = { it.id }, contentType = { "result" }) {
Text(it.title)
}
}
}
}
}
The example focuses on cancellation. The full state also distinguishes loading, failure, no matches and an empty query. Retry needs its own trigger, because distinctUntilChanged ignores the same query string. I'd save search history when the user submits or selects a result, not after every keystroke.
The repository can emit cached results first and then call the API. Retrofit suspend calls support cancellation. A callback API needs an adapter that unregisters in awaitClose. If any work continues separately, it must check that its query is still current before replacing visible results.
For a long result list, I'd create a new Pager for each set of search criteria and use cachedIn(viewModelScope). Each query keeps its own next page cursor. maxSize limits how many loaded items Paging keeps. The Compose list uses stable result IDs and shows a next page error without removing earlier results.
I'd inject the repository and make the delay controllable in tests. The Notes example shows both a manual factory and Hilt.
I'd check these cases.
- Clearing the query during a request.
- Entering the same query again.
- A late response from the old query.
- Local results with a remote failure.
- Changing filters during paging.
I'd measure time from typing to the first result, requests per search and searches with no matches. See UI state production.
Tradeoffs I'd call out
- Debounce interval length. A short debounce, 100 to 150 milliseconds, feels responsive but fires more requests for a fast typist who's going to keep typing anyway. A longer one, 400 to 500 milliseconds, cuts wasted requests but adds a perceptible lag between finishing typing and results appearing. Most teams tune this against actual typing speed data rather than picking a number up front.
- Client side filtering vs always hitting the network. Filtering a locally cached subset of results client side feels instant and costs nothing, but it can only ever be as good as what's cached, which is why it belongs alongside a network search, not instead of one, local results as an instant first pass, network results as the authoritative follow up that replaces or merges with them.
- One combined search and suggestions endpoint vs two separate ones. One endpoint is simpler to call, but suggestions need to be fast and lightweight while full search can afford to do more relevance computation, conflating them either makes suggestions slow or makes full search cheaper than it should be to keep suggestions fast.
What breaks at scale, offline, and on a poor connection
At scale, an unindexed or naive backend search gets slow well before the client does, this is a place where the client design is only half the answer, the server needs real search infrastructure, an inverted index or a dedicated search service, not a LIKE '%query%' scan of a growing table. Offline, search degrades to whatever's in the local cache, recent searches and previously fetched results, with a clear indicator that results may be incomplete rather than silently showing a partial list as if it were exhaustive. On a poor connection, the debounce and request cancellation logic matter together, a new keystroke should cancel the in flight request for the previous, old query, otherwise a slow connection can return an old query's results after a newer one, and render them out of order on screen.
Watch