androidinterview.com

Android System Design Interview Questions

How do you build an offline first app? Explain the architecture.

Tier: EssentialDifficulty: Hard

Design an app whose core screens keep working without an internet connection. Users can read previously saved data and, for features that support it, make changes that will sync later. The app should make pending work and stale data understandable.

The problem

Choose which reads and writes work offline, how long data may be stale and how changes reach the server after reconnection. Saved user work must survive the app closing. Decide how to handle conflicting edits made on different devices.

For example, Ana edits a task on a train with no signal and the screen updates immediately. Reopening the app still shows that edit. When the connection returns, the task syncs. If someone else changed the same task meanwhile, an agreed conflict policy determines the result.

The exercise covers local storage, screen state, a queue of pending changes and synchronization. Start with one concrete feature and its offline promises. Other features may need different rules, especially when an action requires server approval.

Starting the design

Offline first means the local database is the source of truth the UI always reads from. The network is just one of the things that keeps that database up to date, not something the UI waits on. Get that inversion right and most of the rest of the architecture follows from it.

What I'd clarify first

  • Is this read mostly, caching data the user views, or does it also need offline writes, actions taken with no connection that sync later.
  • If writes are in scope, can two devices edit the same record while both are offline. That is what turns this into a conflict resolution problem rather than a caching one.
  • How stale is acceptable. Is "last synced 10 minutes ago" fine, or does the product need to actively signal staleness to the user.

The architecture

  • A local database as the single source of truth, Room, backing every screen's UI state. The UI never talks to the network directly. It observes a Flow from Room, and Room asynchronously emits stored data, possibly empty or stale. A loading state covers the initial query.
  • A repository layer that owns when to go to the network, on screen open, on pull to refresh, or on a background schedule. Whatever comes back is written straight into Room. The UI never learns whether it is looking at a cache or a fresh call. It redraws when Room emits a new value, and that is all it knows.
  • A sync engine built on WorkManager, for anything that has to survive the app being backgrounded or killed. That covers periodic refresh and, critically, flushing queued offline writes once connectivity returns. Its constraints, network required and battery not low, mean you are not reimplementing "wait for a good time to sync" from scratch. The worker itself lives in the sync answer linked below.
  • An outbox for offline writes, if writes are in scope, a local table of pending mutations, each with a status of pending, syncing or failed. The UI renders it optimistically, so a pending message shows immediately while sync happens in the background. Sent, delivered and read remain distinct confirmed states.
  • Paging, once a list is long enough to need it. The same inversion is RemoteMediator. Room is still the source of truth, and paging only decides when to ask the network for more.

How a screen loads

A screen subscribes to a Room Flow and renders immediately with whatever is already local. Empty or stale is still something to show, and a blank loading state over data the device already has is the failure this architecture exists to prevent. The repository, on that same screen open, kicks off a network refresh in the background. When the response comes back it is written into Room, and the UI updates because it is observing that table, not because anyone told it to. If the network call fails, nothing happens to the UI. It is already showing the last good local data, so the failure is a small "couldn't refresh" mark rather than a full screen error.

The code

Reads never touch the network, a refresh writes into the store, and a failed refresh does nothing at all to the UI. That is the pattern in full.

Two decisions are pulled out into their own files, because they are where this design actually goes wrong. The refresh policy decides whether a screen open is worth a request, and it holds off after a failure so an airplane mode phone does not retry on every open. A staleness check on its own is not enough. On a cold start nothing has been fetched yet, so six tabs opening in the same second all pass it, and a single flight guard is what turns that into one request. The refresh also rethrows cancellation instead of recording it, because a user leaving the screen is not a failed request and must not start a cooloff.

The retention policy is the quiet one. A sync engine that only ever inserts turns offline first into the app never gives storage back, and it takes months of real use to notice.

Java

com.androidinterview.offlinefirst.cache.RetentionPolicy.java

package com.androidinterview.offlinefirst.cache;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

// The quiet failure mode of every offline first app, and the reason this file
// exists at all. A sync engine that only ever inserts turns offline first into
// the app never gives storage back, and it is invisible in testing because it
// takes months of real use to show.
//
// Two rules together, because either alone leaves a hole. Age catches the rows
// nobody opens, and the cap catches a user who opens far too many.
//
// This shows the rule, not the query. The real version is one DELETE with the
// same two clauses, because the list this sorts in memory is exactly the list
// that got too big to hold.
public final class RetentionPolicy {

    public record CacheRow(String id, long lastReadAt) {
    }

    private final long maxAgeMillis;
    private final int maxRows;

    public RetentionPolicy(long maxAgeMillis, int maxRows) {
        this.maxAgeMillis = maxAgeMillis;
        this.maxRows = maxRows;
    }

    public List<String> evictable(List<CacheRow> rows, long nowMillis) {
        List<CacheRow> ordered = new ArrayList<>(rows);
        // Id breaks the ties, so which rows survive the cap is the same on
        // every run rather than whatever order the query happened to return.
        ordered.sort(Comparator.comparingLong(CacheRow::lastReadAt).reversed()
                .thenComparing(CacheRow::id));

        List<String> evict = new ArrayList<>();
        for (int i = 0; i < ordered.size(); i++) {
            CacheRow row = ordered.get(i);
            if (i >= maxRows || nowMillis - row.lastReadAt() > maxAgeMillis) evict.add(row.id());
        }
        return evict;
    }
}
package com.androidinterview.offlinefirst.cache;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public final class RetentionPolicy {

    public record CacheRow(String id, long lastReadAt) {
    }

    private final long maxAgeMillis;
    private final int maxRows;

    public RetentionPolicy(long maxAgeMillis, int maxRows) {
        this.maxAgeMillis = maxAgeMillis;
        this.maxRows = maxRows;
    }

    public List<String> evictable(List<CacheRow> rows, long nowMillis) {
        List<CacheRow> ordered = new ArrayList<>(rows);
        ordered.sort(Comparator.comparingLong(CacheRow::lastReadAt).reversed()
                .thenComparing(CacheRow::id));

        List<String> evict = new ArrayList<>();
        for (int i = 0; i < ordered.size(); i++) {
            CacheRow row = ordered.get(i);
            if (i >= maxRows || nowMillis - row.lastReadAt() > maxAgeMillis) evict.add(row.id());
        }
        return evict;
    }
}

com.androidinterview.offlinefirst.data.RefreshPolicy.java

package com.androidinterview.offlinefirst.data;

// When to actually go to the network, which is the decision the repository
// exists to own. Refreshing on every screen open is how a tab bar app fires
// six requests a second while the user flicks between tabs.
public final class RefreshPolicy {

    public enum Trigger {
        SCREEN_OPEN, PULL_TO_REFRESH, BACKGROUND
    }

    private final long ttlMillis;
    private final long failureCooloffMillis;

    public RefreshPolicy(long ttlMillis, long failureCooloffMillis) {
        this.ttlMillis = ttlMillis;
        this.failureCooloffMillis = failureCooloffMillis;
    }

    public boolean shouldRefresh(long lastFetchedAt, long lastFailureAt, long nowMillis, Trigger trigger) {
        // The user asked, so the user gets a request, stale or not. This is the
        // one trigger that ignores every rule below it.
        if (trigger == Trigger.PULL_TO_REFRESH) return true;

        // A cooloff after a failure, so an airplane mode phone does not fire a
        // request per screen open forever. WorkManager's retry policy covers
        // the background attempts while this covers the foreground ones.
        if (lastFailureAt != 0 && nowMillis - lastFailureAt < failureCooloffMillis) return false;

        return nowMillis - lastFetchedAt > ttlMillis;
    }
}
package com.androidinterview.offlinefirst.data;

public final class RefreshPolicy {

    public enum Trigger {
        SCREEN_OPEN, PULL_TO_REFRESH, BACKGROUND
    }

    private final long ttlMillis;
    private final long failureCooloffMillis;

    public RefreshPolicy(long ttlMillis, long failureCooloffMillis) {
        this.ttlMillis = ttlMillis;
        this.failureCooloffMillis = failureCooloffMillis;
    }

    public boolean shouldRefresh(long lastFetchedAt, long lastFailureAt, long nowMillis, Trigger trigger) {
        if (trigger == Trigger.PULL_TO_REFRESH) return true;

        if (lastFailureAt != 0 && nowMillis - lastFailureAt < failureCooloffMillis) return false;

        return nowMillis - lastFetchedAt > ttlMillis;
    }
}

com.androidinterview.offlinefirst.data.Repository.java

package com.androidinterview.offlinefirst.data;

import com.androidinterview.offlinefirst.data.RefreshPolicy.Trigger;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;

// Reads never touch the network, a refresh writes into the store, and a failed
// refresh does nothing at all to the UI. That is the offline first pattern in
// full.
public final class Repository<T> {

    public interface Fetcher<T> {
        List<T> fetch() throws IOException;
    }

    private final Store<T> store;
    private final Fetcher<T> fetcher;
    private final RefreshPolicy policy;
    private final AtomicBoolean inFlight = new AtomicBoolean(false);

    public Repository(Store<T> store, Fetcher<T> fetcher, RefreshPolicy policy) {
        this.store = store;
        this.fetcher = fetcher;
        this.policy = policy;
    }

    // Reads never touch the network. Empty or stale is still something to
    // show, and a blank loading screen over data that is already on the device
    // is the failure this architecture exists to prevent.
    public List<T> current() {
        return store.read();
    }

    public boolean refresh(Trigger trigger, long nowMillis) {
        if (!policy.shouldRefresh(store.lastFetchedAt(), store.lastFailureAt(), nowMillis, trigger)) return false;

        // The other half of the policy, and the half people leave out. On a
        // cold start lastFetchedAt is zero, so six tabs opening in the same
        // second all pass the check above and all six go to the network. One
        // caller wins the flag and the rest return, and they still get the data
        // the moment it lands, because every one of them is observing the store.
        if (!inFlight.compareAndSet(false, true)) return false;
        try {
            store.write(fetcher.fetch(), nowMillis);
            return true;
        } catch (IOException e) {
            // A failed refresh does nothing to the UI on purpose. The screen is
            // already showing the last good data, so this belongs in a small
            // could not refresh mark rather than a full screen error over
            // content the user can perfectly well read.
            store.recordFailure(nowMillis);
            return false;
        } finally {
            inFlight.set(false);
        }
    }
}
package com.androidinterview.offlinefirst.data;

import com.androidinterview.offlinefirst.data.RefreshPolicy.Trigger;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;

public final class Repository<T> {

    public interface Fetcher<T> {
        List<T> fetch() throws IOException;
    }

    private final Store<T> store;
    private final Fetcher<T> fetcher;
    private final RefreshPolicy policy;
    private final AtomicBoolean inFlight = new AtomicBoolean(false);

    public Repository(Store<T> store, Fetcher<T> fetcher, RefreshPolicy policy) {
        this.store = store;
        this.fetcher = fetcher;
        this.policy = policy;
    }

    public List<T> current() {
        return store.read();
    }

    public boolean refresh(Trigger trigger, long nowMillis) {
        if (!policy.shouldRefresh(store.lastFetchedAt(), store.lastFailureAt(), nowMillis, trigger)) return false;

        if (!inFlight.compareAndSet(false, true)) return false;
        try {
            store.write(fetcher.fetch(), nowMillis);
            return true;
        } catch (IOException e) {
            store.recordFailure(nowMillis);
            return false;
        } finally {
            inFlight.set(false);
        }
    }
}

com.androidinterview.offlinefirst.data.Store.java

package com.androidinterview.offlinefirst.data;

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

// The local source of truth. Room sits behind this and the listener is a Flow
// in the app, collected by the ViewModel.
//
// The inversion is the whole architecture. The UI subscribes here once and
// re renders whenever the table changes, so it never asks whether what it is
// looking at came from a cache or from the network, and it never waits on a
// request to show something.
public interface Store<T> {

    // The real signature returns a Flow, and collecting it is what cancels it,
    // so the collector's scope owns the unsubscribe. A bare listener with no
    // way to unregister is a leak, and it stands in here only so the sample
    // compiles with nothing from Android on the classpath.
    void observe(Consumer<List<T>> listener);

    List<T> read();

    // The write carries the fetch time, because staleness is a property of the
    // data and belongs beside it rather than in a separate preferences file
    // that can drift out of step with the rows. A successful write also clears
    // the failure time, because a cooloff should not outlive the failure.
    void write(List<T> values, long fetchedAt);

    long lastFetchedAt();

    // The failure time is persisted for the same reason the fetch time is. Held
    // in a field on the repository it is lost on process death, and surviving
    // process death is the premise of the whole architecture.
    void recordFailure(long atMillis);

    long lastFailureAt();
}
package com.androidinterview.offlinefirst.data;

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

public interface Store<T> {

    void observe(Consumer<List<T>> listener);

    List<T> read();

    void write(List<T> values, long fetchedAt);

    long lastFetchedAt();

    void recordFailure(long atMillis);

    long lastFailureAt();
}

Kotlin

com.androidinterview.offlinefirst.cache.RetentionPolicy.kt

package com.androidinterview.offlinefirst.cache

data class CacheRow(val id: String, val lastReadAt: Long)

// The quiet failure mode of every offline first app, and the reason this file
// exists at all. A sync engine that only ever inserts turns offline first into
// the app never gives storage back, and it is invisible in testing because it
// takes months of real use to show.
//
// Two rules together, because either alone leaves a hole. Age catches the rows
// nobody opens, and the cap catches a user who opens far too many.
//
// This shows the rule, not the query. The real version is one DELETE with the
// same two clauses, because the list this sorts in memory is exactly the list
// that got too big to hold.
class RetentionPolicy(private val maxAgeMillis: Long, private val maxRows: Int) {

    // Id breaks the ties, so which rows survive the cap is the same on every
    // run rather than whatever order the query happened to return.
    fun evictable(rows: List<CacheRow>, nowMillis: Long): List<String> =
        rows.sortedWith(compareByDescending<CacheRow> { it.lastReadAt }.thenBy { it.id })
            .filterIndexed { index, row -> index >= maxRows || nowMillis - row.lastReadAt > maxAgeMillis }
            .map { it.id }
}
package com.androidinterview.offlinefirst.cache

data class CacheRow(val id: String, val lastReadAt: Long)

class RetentionPolicy(private val maxAgeMillis: Long, private val maxRows: Int) {

    fun evictable(rows: List<CacheRow>, nowMillis: Long): List<String> =
        rows.sortedWith(compareByDescending<CacheRow> { it.lastReadAt }.thenBy { it.id })
            .filterIndexed { index, row -> index >= maxRows || nowMillis - row.lastReadAt > maxAgeMillis }
            .map { it.id }
}

com.androidinterview.offlinefirst.data.RefreshPolicy.kt

package com.androidinterview.offlinefirst.data

enum class Trigger { SCREEN_OPEN, PULL_TO_REFRESH, BACKGROUND }

// When to actually go to the network, which is the decision the repository
// exists to own. Refreshing on every screen open is how a tab bar app fires
// six requests a second while the user flicks between tabs.
class RefreshPolicy(private val ttlMillis: Long, private val failureCooloffMillis: Long) {

    fun shouldRefresh(lastFetchedAt: Long, lastFailureAt: Long, nowMillis: Long, trigger: Trigger) = when {
        // The user asked, so the user gets a request, stale or not.
        trigger == Trigger.PULL_TO_REFRESH -> true

        // A cooloff after a failure, so an airplane mode phone does not fire a
        // request per screen open forever. WorkManager's retry policy covers
        // the background attempts and this covers the foreground ones.
        lastFailureAt != 0L && nowMillis - lastFailureAt < failureCooloffMillis -> false

        else -> nowMillis - lastFetchedAt > ttlMillis
    }
}
package com.androidinterview.offlinefirst.data

enum class Trigger { SCREEN_OPEN, PULL_TO_REFRESH, BACKGROUND }

class RefreshPolicy(private val ttlMillis: Long, private val failureCooloffMillis: Long) {

    fun shouldRefresh(lastFetchedAt: Long, lastFailureAt: Long, nowMillis: Long, trigger: Trigger) = when {
        trigger == Trigger.PULL_TO_REFRESH -> true

        lastFailureAt != 0L && nowMillis - lastFailureAt < failureCooloffMillis -> false

        else -> nowMillis - lastFetchedAt > ttlMillis
    }
}

com.androidinterview.offlinefirst.data.Repository.kt

package com.androidinterview.offlinefirst.data

import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.cancellation.CancellationException

// Reads never touch the network, a refresh writes into the store, and a failed
// refresh does nothing at all to the UI. That is the offline first pattern in
// full.
class Repository<T>(
    private val store: Store<T>,
    private val fetch: suspend () -> List<T>,
    private val policy: RefreshPolicy,
) {
    private val inFlight = AtomicBoolean(false)

    // Reads never touch the network. Empty or stale is still something to
    // show, and a blank loading screen over data already on the device is the
    // failure this architecture exists to prevent.
    fun current(): List<T> = store.read()

    suspend fun refresh(trigger: Trigger, nowMillis: Long): Boolean {
        if (!policy.shouldRefresh(store.lastFetchedAt, store.lastFailureAt, nowMillis, trigger)) return false

        // The other half of the policy, and the half people leave out. On a
        // cold start lastFetchedAt is zero, so six tabs opening in the same
        // second all pass the check above and all six go to the network. One
        // caller wins the flag and the rest return, and they still get the data
        // the moment it lands, because every one of them is observing the store.
        if (!inFlight.compareAndSet(false, true)) return false
        return try {
            store.write(fetch(), nowMillis)
            true
        } catch (e: CancellationException) {
            // The screen closed while the request was in flight. That is not a
            // failure, so it is rethrown rather than recorded. runCatching here
            // would swallow it, put the repository into its failure cooloff,
            // and quietly refuse the next screen open the user actually makes.
            throw e
        } catch (e: IOException) {
            // A failed refresh does nothing to the UI on purpose. The screen
            // already shows the last good data, so this belongs in a small
            // could not refresh mark rather than a full screen error over
            // content the user can perfectly well read.
            store.recordFailure(nowMillis)
            false
        } finally {
            inFlight.set(false)
        }
    }
}
package com.androidinterview.offlinefirst.data

import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.cancellation.CancellationException

class Repository<T>(
    private val store: Store<T>,
    private val fetch: suspend () -> List<T>,
    private val policy: RefreshPolicy,
) {
    private val inFlight = AtomicBoolean(false)

    fun current(): List<T> = store.read()

    suspend fun refresh(trigger: Trigger, nowMillis: Long): Boolean {
        if (!policy.shouldRefresh(store.lastFetchedAt, store.lastFailureAt, nowMillis, trigger)) return false

        if (!inFlight.compareAndSet(false, true)) return false
        return try {
            store.write(fetch(), nowMillis)
            true
        } catch (e: CancellationException) {
            throw e
        } catch (e: IOException) {
            store.recordFailure(nowMillis)
            false
        } finally {
            inFlight.set(false)
        }
    }
}

com.androidinterview.offlinefirst.data.Store.kt

package com.androidinterview.offlinefirst.data

// The local source of truth. Room sits behind this, and observe is a Flow in
// the app, collected by the ViewModel. Collecting the Flow is what cancels it,
// so the collector's scope owns the unsubscribe.
//
// The inversion is the whole architecture. The UI subscribes once and renders
// again whenever the table changes, so it never asks whether what it sees came
// from a cache or a network call, and it never waits on a request to show
// something.
interface Store<T> {
    fun observe(listener: (List<T>) -> Unit)
    fun read(): List<T>

    // The write carries the fetch time, because staleness is a property of the
    // data and belongs beside it rather than in a preferences file that can
    // drift out of step with the rows. A successful write also clears the
    // failure time, because a cooloff should not outlive the failure.
    fun write(values: List<T>, fetchedAt: Long)

    val lastFetchedAt: Long

    // The failure time is persisted for the same reason the fetch time is. Held
    // in a field on the repository it is lost on process death, and surviving
    // process death is the premise of the whole architecture.
    fun recordFailure(atMillis: Long)

    val lastFailureAt: Long
}
package com.androidinterview.offlinefirst.data

interface Store<T> {
    fun observe(listener: (List<T>) -> Unit)
    fun read(): List<T>

    fun write(values: List<T>, fetchedAt: Long)

    val lastFetchedAt: Long

    fun recordFailure(atMillis: Long)

    val lastFailureAt: Long
}

The write half, the outbox and its backoff, is written out under handling data syncing on an unstable network, and the conflict rule it needs is in the Google Notes answer. Implementing a caching mechanism asks the same question at the scale of one screen, one table and one staleness check. This answer is the architecture around that, the refresh policy, the retention policy, and a write path that survives the app being killed.

How I'd build this on Android

Offline first read and sync pathsCompose and ViewModel to Repository, observe and save. Repository to Room, transaction. Sync worker to Room, merge saved changes. Sync worker to Authenticated API, push and pull.observe and savetransactionmerge saved changespush and pullCompose and ViewModelObserve saved data while visibleRepositorySave writes and prepare UI dataSync workerSaved work that waits for networkRoomData and sync recordsAuthenticated APIVersions and safe retries
Offline first read and sync paths
UI data returns from Room. The worker never publishes a separate competing copy directly to the screen.

I'd explain MVVM with one read and one write. A read goes from the DAO's Flow through the repository and ViewModel to Compose. A write starts in a UI callback, then the ViewModel asks the repository to save both the changed row and an upload operation. A worker sends it later. The UI reads local data, while the server still decides permissions, shared versions and whether a change is accepted.

interface TaskRepository {
    fun observeTasks(): Flow<List<Task>>
    suspend fun rename(id: String, title: String)
}

// Repository write slice. Both DAOs belong to the same Room database.
suspend fun renameTask(db: TaskDatabase, id: String, title: String) {
    db.withTransaction {
        db.tasks().rename(id, title)
        db.outbox().insert(RenameOperation(
            operationId = UUID.randomUUID().toString(),
            taskId = id, title = title,
        ))
    }
}

Task, TaskDatabase and RenameOperation stand for this feature's data types. Before saving a rename, the DAO checks that the task exists. It should not queue a change for a missing row. After the transaction commits, the repository schedules a worker. If the app dies between saving and scheduling, startup and periodic checks schedule the pending work again. Retries reuse the operation ID, and an acknowledgement clears only the operation the server accepted.

The ViewModel uses stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initialState). The route subscribes with collectAsStateWithLifecycle(). Loading must look different from an empty database. The screen also distinguishes a change saved on this phone, one waiting to sync and one rejected by the server. WorkManager status alone cannot tell the user which task needs attention.

I'd inject the database, signed in API and scheduler into the repository, then inject the repository into the ViewModel. One database per account helps prevent reading another account's data. The design also needs migrations, limits on retained data and logout cleanup. Unsynced edits must never be removed as if they were cached responses. Large read lists can use a Room PagingSource and RemoteMediator, with small UI models and stable list IDs.

The Notes answer shows the entity, DAO, repository, worker, ViewModel, Compose and dependency injection code.

I'd check these cases.

  • Offline edits.
  • Process restart.
  • A migration with pending writes.
  • Changes on two devices.
  • A server rejection.

See Android offline first guidance.

Tradeoffs I'd call out

  • Room as source of truth vs a thinner in memory cache. Making Room the actual source of truth, not just a cache the network falls back to, means every screen works offline for free and consistently. It also means every piece of remote data needs a table and a migration story, which is more upfront schema work than caching responses in memory.
  • Optimistic writes vs waiting for server confirmation. Showing a pending action as done immediately makes the app feel fast and usable offline. It also means the UI can be showing something that later fails to sync, so it needs a clear way to surface that failure and let the user retry or discard it, rather than silently dropping it.
  • Conflict resolution strategy. If two offline edits to the same record eventually sync from two devices, something has to decide the winner. Last write wins is simple and may suit data where losing a competing edit is explicitly acceptable, but it silently discards one edit. Anything where losing an edit is unacceptable needs real merging, or the conflict surfaced to the user, which is meaningfully more work.

What breaks at scale, offline, and on a poor connection

  • At scale, an unbounded local database is the quiet failure mode. A sync engine that only inserts and never expires old data eventually turns offline first into an app that never frees storage, so a retention policy has to exist even though it is easy to skip in a first pass.
  • Offline, this whole architecture is the point. The honest test is whether the app is fully usable in airplane mode for anything the user has previously loaded.
  • On a poor connection, retry and backoff are what separate a resilient app from one that quietly stops syncing. A flaky connection should degrade to slower, less frequent syncs, not to an app that gives up after the first failure.

Watch