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
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 the results, so scrolling a large result set never holds more than a couple of pages in memory.
- A write to the local recent-searches table, which is what fills the empty state the next time the user taps into the field.
The code
In the app the whole pipeline is four operators, and it is worth being able to write them from memory.
queryFlow
.debounce(350) // one emission after the user pauses
.distinctUntilChanged() // a typed and deleted space fires nothing
.filter { it.length >= 2 } // one letter matches most of the catalogue
.flatMapLatest { repo.search(it) } // a new query cancels the previous search
flatMapLatest is the important one. It cancels the collection for the previous query, which is the first half of the out of order fix and most of why the sequence file below is a Java problem rather than a Kotlin one.
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. Paging and the API surface are described above rather than coded, they add nothing to the design.
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);
}
}
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;
}
}
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);
}
}
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;
}
}
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
}
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()
}
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, now-stale 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