androidinterview.com

Android System Design Interview Questions

Implement a caching mechanism.

Tier: CommonDifficulty: Medium

Room is the cache. The screen observes a table, the network writes into that table, and a failed refresh leaves the last good rows on screen. One lastFetchedAt timestamp beside the rows decides whether a screen open is worth a network call at all.

This is deliberately narrower than the offline-first answer. That one is the architecture, with a sync engine, an outbox for offline writes and a retention policy. This one is one screen, one table and one staleness rule, and it stops there. It is also narrower than the caching library, which is the reusable mechanism. Here the mechanism is Room, and the work is deciding when to trust it.

What I'd clarify first

  • How stale is acceptable. Is a minute fine, or an hour, or must every screen open attempt a fetch and fall back to the cache only on failure.
  • Does the cache need to survive process death, which puts it in Room rather than in memory for the repository's lifetime.
  • Is this one screen and one dataset, or does the mechanism need to generalize across several unrelated calls.

A concrete implementation

A repository between the ViewModel and both a Room DAO and a Retrofit service is the standard shape. The screen collects the table, and the two refresh entry points write into it.

class ArticleRepository(
    private val api: ArticleApi,
    private val dao: ArticleDao,
    private val maxAge: Duration,
    private val now: () -> Long = System::currentTimeMillis,
) {
    private val inFlight = AtomicBoolean(false)

    // What the screen collects. Room emits the cached rows now, and again when refresh lands.
    fun observeArticles(onChanged: (List<Article>) -> Unit) = dao.observe(onChanged)

    // Screen open. A fresh enough cache skips the network entirely.
    fun refreshIfStale() {
        val fetchedAt = dao.lastFetchedAtMillis()
        if (fetchedAt == null || now() - fetchedAt > maxAge.inWholeMilliseconds) refresh()
    }

    // Pull to refresh. One request at a time, a second caller gets its rows from the table.
    fun refresh(): Boolean {
        if (!inFlight.compareAndSet(false, true)) return true
        return try {
            dao.replaceAll(api.fetchArticles(), now())
            true
        } catch (networkDown: IOException) {
            false
        } finally {
            inFlight.set(false)
        }
    }
}

The UI collects observeArticles() and always has something to render, because it reads Room and never the network call. refreshIfStale() runs on screen open and refresh() on pull-to-refresh. On success the rows are written into Room, and that write is what updates the UI, through Room emitting again rather than the ViewModel pushing state. On failure nothing breaks. The screen keeps the last cached rows, and the false becomes a small banner rather than an error screen.

The lastFetchedAt timestamp lives in the same store as the rows it describes, so the staleness check survives process death exactly as the data does. The full version below names the Room and Retrofit seams as plain interfaces, so the shape is the same on any stack.

Java

com.androidinterview.cachingmechanism.data.Article.java

package com.androidinterview.cachingmechanism.data;

public record Article(long id, String title, String body) {
}

com.androidinterview.cachingmechanism.data.ArticleApi.java

package com.androidinterview.cachingmechanism.data;

import java.io.IOException;
import java.util.List;

// The network, and it throws. Modelling failure in the signature is what makes
// the repository below have to decide what a failed refresh means, rather than
// letting it leak up to the screen as a crash.
public interface ArticleApi {

    List<Article> fetchArticles() throws IOException;

    Article save(Article article) throws IOException;
}

com.androidinterview.cachingmechanism.data.ArticleDao.java

package com.androidinterview.cachingmechanism.data;

import java.time.Instant;
import java.util.List;
import java.util.function.Consumer;

// The Room shaped seam. observe is the important one, because the screen
// subscribes to the table and never to the network call, which is what makes a
// write anywhere show up everywhere. In a real app observe is a Flow and the
// subscription ends when the collecting scope does, so nothing here needs an
// unregister.
//
// fetchedAt lives in the same store as the rows it describes, so the staleness
// check survives process death exactly as the data does.
public interface ArticleDao {

    void observe(Consumer<List<Article>> onChanged);

    void replaceAll(List<Article> articles, Instant fetchedAt);

    void upsert(Article article);

    Instant lastFetchedAt();
}

com.androidinterview.cachingmechanism.repository.ArticleRepository.java

package com.androidinterview.cachingmechanism.repository;

import com.androidinterview.cachingmechanism.data.Article;
import com.androidinterview.cachingmechanism.data.ArticleApi;
import com.androidinterview.cachingmechanism.data.ArticleDao;

import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;

// One source of truth, and it is the database. The screen observes the table,
// refresh writes into the table, and nothing ever hands rows to the UI
// directly. That single rule is the answer to this question, and it is what
// makes an edit on one screen visible on another without any invalidation
// message passing between them.
public final class ArticleRepository {

    private final ArticleApi api;
    private final ArticleDao dao;
    private final Duration maxAge;
    // The single flight guard. Two screens opening together both read the same
    // stale lastFetchedAt, and without this both would hit the network.
    private final AtomicBoolean inFlight = new AtomicBoolean(false);

    public ArticleRepository(ArticleApi api, ArticleDao dao, Duration maxAge) {
        this.api = api;
        this.dao = dao;
        this.maxAge = maxAge;
    }

    // What the screen calls. It emits immediately with whatever was cached, so
    // there is no empty loading state on a warm start, and it emits again the
    // moment refresh lands.
    public void observeArticles(Consumer<List<Article>> onChanged) {
        dao.observe(onChanged);
    }

    // Called on screen open. A fresh enough cache skips the network entirely,
    // which is the difference between a caching mechanism and a cache shaped
    // decoration on top of a network call that always runs.
    public void refreshIfStale() {
        Instant fetchedAt = dao.lastFetchedAt();
        boolean stale = fetchedAt == null
                || Duration.between(fetchedAt, Instant.now()).compareTo(maxAge) > 0;
        if (stale) {
            refresh();
        }
    }

    // Pull to refresh calls this directly, because an explicit user gesture
    // means the user is telling you the cache is stale.
    //
    // One request at a time. A second caller while one is in flight makes no
    // request and gets true, because it has nothing to show a banner for. It
    // does not need the result either. It observes the table, and the table
    // emits when the first refresh lands.
    public boolean refresh() {
        if (!inFlight.compareAndSet(false, true)) {
            return true;
        }
        try {
            dao.replaceAll(api.fetchArticles(), Instant.now());
            return true;
        } catch (IOException networkDown) {
            // Nothing to undo. The screen is reading the table, so it keeps
            // rendering the last good copy, and the caller turns this false
            // into a small banner rather than an error screen.
            return false;
        } finally {
            inFlight.set(false);
        }
    }

    // The write path goes through the same table, and that is the whole point.
    // Write locally first so the UI updates now, then push. An edit that only
    // told the server would leave every other screen showing the old row until
    // its own refresh happened to fire.
    //
    // This is the thin version. If the push fails the local row stands and
    // nothing is queued to retry it. A real app puts an outbox here, which is
    // the data syncing answer, and this answer owns only the read path.
    public void save(Article article) throws IOException {
        dao.upsert(article);
        dao.upsert(api.save(article));
    }
}

Kotlin

com.androidinterview.cachingmechanism.data.ArticleStore.kt

package com.androidinterview.cachingmechanism.data

import java.io.IOException

data class Article(val id: Long, val title: String, val body: String)

// The network, and it throws. Modelling failure in the signature is what makes
// the repository decide what a failed refresh means, rather than letting it
// leak up to the screen as a crash.
interface ArticleApi {
    @Throws(IOException::class)
    fun fetchArticles(): List<Article>

    @Throws(IOException::class)
    fun save(article: Article): Article
}

// The Room shaped seam. observe is the important one, because the screen
// subscribes to the table and never to the network call, which is what makes a
// write anywhere show up everywhere. In a real app observe is a Flow and the
// subscription ends when the collecting scope does, so nothing here needs an
// unregister.
//
// fetchedAt lives in the same store as the rows it describes, so the staleness
// check survives process death exactly as the data does.
interface ArticleDao {
    fun observe(onChanged: (List<Article>) -> Unit)
    fun replaceAll(articles: List<Article>, fetchedAtMillis: Long)
    fun upsert(article: Article)
    fun lastFetchedAtMillis(): Long?
}

com.androidinterview.cachingmechanism.repository.ArticleRepository.kt

package com.androidinterview.cachingmechanism.repository

import com.androidinterview.cachingmechanism.data.Article
import com.androidinterview.cachingmechanism.data.ArticleApi
import com.androidinterview.cachingmechanism.data.ArticleDao
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration

// One source of truth, and it is the database. The screen observes the table,
// refresh writes into the table, and nothing hands rows to the UI directly.
// That single rule is the answer here, and it is what makes an edit on one
// screen visible on another with no invalidation message between them.
class ArticleRepository(
    private val api: ArticleApi,
    private val dao: ArticleDao,
    private val maxAge: Duration,
    private val now: () -> Long = System::currentTimeMillis,
) {
    // The single flight guard. Two screens opening together both read the same
    // stale lastFetchedAt, and without this both would hit the network.
    private val inFlight = AtomicBoolean(false)

    // What the screen collects. It emits with whatever was cached, so a warm
    // start has no empty loading state, and it emits again when refresh lands.
    fun observeArticles(onChanged: (List<Article>) -> Unit) = dao.observe(onChanged)

    // Called on screen open. A fresh enough cache skips the network entirely,
    // which is the difference between a caching mechanism and a cache shaped
    // decoration on a network call that runs every time anyway.
    fun refreshIfStale() {
        val fetchedAt = dao.lastFetchedAtMillis()
        if (fetchedAt == null || now() - fetchedAt > maxAge.inWholeMilliseconds) refresh()
    }

    // Pull to refresh calls this directly, because an explicit gesture is the
    // user telling you the cache is stale.
    //
    // One request at a time. A second caller while one is in flight makes no
    // request and gets true, because it has nothing to show a banner for. It
    // does not need the result either. It observes the table, and the table
    // emits when the first refresh lands.
    fun refresh(): Boolean {
        if (!inFlight.compareAndSet(false, true)) return true
        return try {
            dao.replaceAll(api.fetchArticles(), now())
            true
        } catch (networkDown: IOException) {
            // Nothing to undo. The screen reads the table, so it keeps
            // rendering the last good copy and the caller turns this false
            // into a banner rather than an error screen.
            false
        } finally {
            inFlight.set(false)
        }
    }

    // The write path goes through the same table, and that is the point. Write
    // locally first so the UI updates now, then push. An edit that only told
    // the server leaves every other screen on the old row until its own
    // refresh happens to fire.
    //
    // This is the thin version, and it throws when the push fails. The local
    // row then stands and nothing is queued to retry it. A real app puts an
    // outbox here, which is the data syncing answer, and this answer owns only
    // the read path.
    @Throws(IOException::class)
    fun save(article: Article) {
        dao.upsert(article)
        dao.upsert(api.save(article))
    }
}

Tradeoffs I'd call out

  • Cache-then-network vs network-then-cache-fallback. Showing the cache first and refreshing behind it means the UI never blocks on a round trip, but the user may briefly see stale rows. Waiting on the network and falling back to the cache only on failure guarantees freshness when it succeeds. The cost is a loading state on every screen open, even when a good cached answer already existed.
  • A fixed staleness threshold vs per feature tuning. One global rule is simple to reason about, but data differs in volatility. A profile barely changes and a live price changes constantly. Per feature thresholds are more correct, and every repository then needs its own tuned value.
  • Writing straight into Room vs an in-memory cache in front of it. Writing network results into Room means the cache and the persisted data are one thing, so there is nothing to keep in sync. A memory cache in front of Room is faster for very hot data. It is also a second cache that can disagree with the first unless both are invalidated together.

What breaks at scale

The bug this pattern actually hits is two screens opening at the same moment. Both read the same stale lastFetchedAt, both decide to refresh, and the network is hit twice for the same rows. The staleness gate alone does not stop that, because it only sees the timestamp of the last fetch that finished. The repository holds an in-flight flag, and a second caller while a refresh is running skips the request and is done. It does not need the result. It observes the table, and the table emits when the first refresh lands. Six tabs opening together are one request.

The write path is deliberately thin here. save writes the row locally and then pushes it, and if the push fails the local row stands with nothing queued to retry it. That is enough for this question and not for a real app. The outbox that fixes it is in the data syncing answer.

Watch