Skip to content
Commitwise
Menu
System Design

Mobile: pagination and real-time updates

Design long lists that load page by page and stay up to date while open, with cursor-based paging, guarding against overlapping and stale page loads, Paging 3 and SwiftUI infinite lists, choosing between polling, server-sent events, WebSockets and push, and reconnecting on mobile without missing or duplicating updates, with code in Kotlin, Swift and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A delivery app has an orders screen for restaurant staff: hundreds of orders a day, newest first, and new ones must appear within seconds, because food goes cold. The first version loads all orders at once (slow on day one, unusable by month two) and refreshes the whole list every 10 seconds, which drains the tablet’s battery, jumps the scroll position to the top, and still shows new orders up to 10 seconds late.

Long lists that change while you look at them are one of the most common mobile system design topics: how to page through them efficiently, and how to push changes into them without breaking what the user is looking at.

New to some of these? Open what you need, or skip ahead.

Basics:What is a cursor?

A cursor is a marker the server returns with each page, meaning “this is where the last page ended”. The app sends it back to get the next page. Unlike page numbers, cursors don’t produce duplicates when new items are added at the top. The Design a news feed lesson explains why.

Basics:What is a WebSocket?

A WebSocket is a connection that stays open between the app and the server, so either side can send messages at any time. The server can tell the app “new order” the moment it happens, without the app asking. The Design a chat app lesson uses them.

Part Not started

Paging through a long list

The app shows the first 20 orders and asks for more only when the user scrolls near the bottom, using the cursor from the last page. Three rules keep it correct:

  • One page request at a time. Scrolling fires “near the end” many times; only the first should start a request.
  • Stop at the end. When the server returns no next cursor, there’s nothing more to load.
  • Forget stale requests. If the user pulls to refresh or changes the filter while page 3 is loading, the answer for page 3 belongs to the old list and must be thrown away, not appended to the new one.

On Android, the Paging 3 library implements all of this. On iOS, a small “paginator” object in the view model does it.

Client-side paging state is a small state machine: items, nextCursor, isLoading, endReached, error, plus a generation (or the identity of the query) that increments on refresh and on any parameter change; responses carry the generation they were requested for and are discarded if it no longer matches. Prefetch distance should cover a fast fling (a page or so ahead), deduplication by id protects against server-side overlap, and errors on append should keep existing items and offer a retry footer rather than replace the list. Paging 3 (PagingSource for network-only, RemoteMediator + Room PagingSource for offline-first, where Room is the source of truth and invalidation drives updates) gives this for free on Android, including cancellation on refresh. Server side, cursor pagination on (sortKey, id) with stable sort order is non-negotiable for lists that change.

@Observable @MainActor
final class OrdersPaginator {
    private(set) var orders: [Order] = []
    private(set) var isLoading = false
    private(set) var endReached = false
    private(set) var appendFailed = false

    private var nextCursor: String?
    private var generation = 0
    private var loadTask: Task<Void, Never>?
    private let api: OrdersAPI
    var filter: OrderFilter = .open { didSet { refresh() } }

    init(api: OrdersAPI) { self.api = api }

    func refresh() {
        generation += 1                                  // older responses become stale
        loadTask?.cancel()
        orders = []; nextCursor = nil; endReached = false; isLoading = false
        loadMore()
    }

    func loadMoreIfNeeded(currentItem: Order) {
        guard let index = orders.firstIndex(where: { $0.id == currentItem.id }),
              index >= orders.count - 10 else { return }   // about half a page ahead
        loadMore()
    }

    func loadMore() {
        guard !isLoading, !endReached else { return }      // one request at a time
        isLoading = true
        appendFailed = false
        let requested = generation
        loadTask = Task {
            defer { if requested == generation { isLoading = false } }
            do {
                let page = try await api.orders(filter: filter, cursor: nextCursor, limit: 20)
                guard requested == generation else { return }          // stale: drop it
                let known = Set(orders.map(\.id))
                orders += page.orders.filter { !known.contains($0.id) }
                nextCursor = page.nextCursor
                endReached = page.nextCursor == nil
            } catch {
                if requested == generation, !(error is CancellationError) { appendFailed = true }
            }
        }
    }
}
// Android: Paging 3 with Room as the source of truth and a RemoteMediator filling it
@OptIn(ExperimentalPagingApi::class)
class OrdersRemoteMediator(
    private val api: OrdersApi,
    private val db: AppDatabase,
    private val filter: OrderFilter,
) : RemoteMediator<Int, OrderEntity>() {

    override suspend fun load(loadType: LoadType, state: PagingState<Int, OrderEntity>): MediatorResult {
        val cursor = when (loadType) {
            LoadType.REFRESH -> null
            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
            LoadType.APPEND -> db.remoteKeys().forFilter(filter)?.nextCursor
                ?: return MediatorResult.Success(endOfPaginationReached = true)
        }
        return try {
            val page = api.orders(filter, cursor, limit = state.config.pageSize)
            db.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    db.orders().clear(filter)
                    db.remoteKeys().clear(filter)
                }
                db.orders().upsertAll(page.orders.map { it.toEntity(filter) })
                db.remoteKeys().upsert(RemoteKey(filter, page.nextCursor))
            }
            MediatorResult.Success(endOfPaginationReached = page.nextCursor == null)
        } catch (e: IOException) {
            MediatorResult.Error(e)
        }
    }
}

@HiltViewModel
class OrdersViewModel @Inject constructor(api: OrdersApi, db: AppDatabase) : ViewModel() {
    private val filter = MutableStateFlow(OrderFilter.OPEN)

    @OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class)
    val orders: Flow<PagingData<Order>> = filter.flatMapLatest { f ->   // a new filter cancels the old pager
        Pager(
            config = PagingConfig(pageSize = 20, prefetchDistance = 10),
            remoteMediator = OrdersRemoteMediator(api, db, f),
            pagingSourceFactory = { db.orders().pagingSource(f) },
        ).flow
    }.map { data -> data.map(OrderEntity::toDomain) }
        .cachedIn(viewModelScope)

    fun setFilter(f: OrderFilter) { filter.value = f }
}
// React: the same generation check in a hook
export function useOrders(api: OrdersApi, filter: OrderFilter) {
  const [orders, setOrders] = useState<Order[]>([]);
  const state = useRef({ cursor: null as string | null, loading: false, end: false, generation: 0 });

  useEffect(() => {                                    // filter changed: start a new generation
    state.current = { cursor: null, loading: false, end: false, generation: state.current.generation + 1 };
    setOrders([]);
    void loadMore();
  }, [filter]);

  const loadMore = useCallback(async () => {
    const s = state.current;
    if (s.loading || s.end) return;                    // one request at a time
    s.loading = true;
    const requested = s.generation;
    try {
      const page = await api.orders(filter, s.cursor, 20);
      if (requested !== state.current.generation) return;   // stale: drop it
      setOrders((old) => {
        const known = new Set(old.map((o) => o.id));
        return [...old, ...page.orders.filter((o) => !known.has(o.id))];
      });
      s.cursor = page.nextCursor;
      s.end = page.nextCursor === null;
    } finally {
      if (requested === state.current.generation) s.loading = false;
    }
  }, [api, filter]);

  return { orders, loadMore };
}
  • generation goes up on every refresh or filter change. Each request remembers the generation it was started in, and its answer is dropped if the list has moved on.
  • isLoading blocks a second request while one is running; endReached stops requests after the last page.
  • Deduplicating by id protects the list if the server ever returns an item twice.
  • On Android, flatMapLatest starts a fresh Pager when the filter changes and cancels the old one; the RemoteMediator writes pages into Room, and the list shows whatever Room has, so it also works offline.
Spot the bugOpen orders show a few closed ones after switching filters

Tap the line with the bug.

func loadMore() {
    guard !isLoading, !endReached else { return }
    isLoading = true
    Task {
        let page = try? await api.orders(filter: filter, cursor: nextCursor, limit: 20)
        orders += page?.orders ?? []
        nextCursor = page?.nextCursor
        isLoading = false
    }
}

func setFilter(_ newFilter: OrderFilter) {
    filter = newFilter
    orders = []; nextCursor = nil; isLoading = false
    loadMore()
}
fun loadMore() {
    if (isLoading || endReached) return
    isLoading = true
    viewModelScope.launch {
        val page = runCatching { api.orders(filter, nextCursor, 20) }.getOrNull()
        _orders.update { it + page?.orders.orEmpty() }
        nextCursor = page?.nextCursor
        isLoading = false
    }
}

fun setFilter(newFilter: OrderFilter) {
    filter = newFilter
    _orders.value = emptyList(); nextCursor = null; isLoading = false
    loadMore()
}
async function loadMore() {
  if (s.loading || s.end) return;
  s.loading = true;
  const page = await api.orders(s.filter, s.cursor, 20).catch(() => null);
  setOrders((old) => [...old, ...(page?.orders ?? [])]);
  s.cursor = page?.nextCursor ?? null;
  s.loading = false;
}

function setFilter(filter: OrderFilter) {
  s.filter = filter;
  setOrders([]); s.cursor = null; s.loading = false;
  void loadMore();
}

  • Client paging state: items, nextCursor, isLoading, endReached, append error, and a generation bumped on refresh or parameter change; drop responses from older generations and cancel in-flight loads.
  • Prefetch roughly a page ahead; dedupe by id; append errors keep items and show a retry footer.
  • Android: Paging 3 (PagingSource, RemoteMediator + Room for offline-first, flatMapLatest over parameters, cachedIn).
  • Server: cursor pagination on a stable (sortKey, id) order.

Check this part

  1. Why must only one page request run at a time?

  2. The filter changes while a page is loading. What should happen to that page when it arrives?

  3. What does a RemoteMediator do in Paging 3?

Load lists in pages with a cursor, fetching the next page when the user nears the end. Allow one page load at a time, and drop results that belong to an older generation of the list (after a refresh or a filter change). On Android, Paging 3 handles this, optionally with a RemoteMediator writing pages into Room; on iOS, a small paginator object does.

Part Not started

Keeping the list live

There are several ways for an app to learn about changes, from simplest to most “live”:

  • Polling: ask “anything new?” every N seconds. Simple, but either slow (long interval) or wasteful (short interval).
  • Long polling: ask, and the server holds the request open until something happens or about 30 seconds pass.
  • Server-sent events (SSE): one long-lived HTTP response the server keeps writing events into. One direction: server to app.
  • WebSockets: a two-way connection; good when the app also sends a lot (chat, live collaboration).
  • Push notifications: for when the app isn’t open. The system delivers them, even if the app is closed.

On a phone, live connections only make sense while the screen is visible. When the app goes to the background, close the connection (the system will suspend it anyway); when it comes back, reconnect and catch up on what was missed.

Channel choice follows from direction, frequency and scale. SSE is often underrated: plain HTTP (works through proxies and HTTP/2 multiplexing), built-in reconnection with Last-Event-ID on the web, and enough for server-to-client feeds like order boards, prices and notifications. WebSockets suit bidirectional, high-frequency traffic but need sticky connection management, heartbeats, and your own resume protocol. Either way, the correctness comes from the protocol, not the transport: every change carries a monotonically increasing sequence number (per user or per stream) or an event id; on (re)connect, the client sends the last one it applied, and the server replays what’s missing from a retained log, or tells the client to do a full refresh if it’s too far behind. Without this, every reconnect (which on mobile happens constantly: network switches, background, tunnels) is a silent gap. Client-side, apply events idempotently (upsert by id, ignore stale versions), and avoid shifting content under the user: new items at the top go into a “3 new orders” banner when the user has scrolled down.

// iOS: a WebSocket that resumes from the last applied sequence number
@MainActor
final class OrderUpdates {
    private var task: URLSessionWebSocketTask?
    private var lastSequence: Int
    private let onEvent: (OrderEvent) -> Void

    init(lastSequence: Int, onEvent: @escaping (OrderEvent) -> Void) {
        self.lastSequence = lastSequence
        self.onEvent = onEvent
    }

    func run() async {                                   // call from .task: cancelled when the view goes away
        var attempt = 0
        while !Task.isCancelled {
            let url = URL(string: "wss://api.example.com/orders/live?after=" + String(lastSequence))!
            let task = URLSession.shared.webSocketTask(with: url)
            self.task = task
            task.resume()
            do {
                while !Task.isCancelled {
                    let message = try await task.receive()
                    guard case .string(let text) = message,
                          let event = try? OrderEvent.decode(text) else { continue }
                    guard event.sequence > lastSequence else { continue }   // already applied
                    onEvent(event)
                    lastSequence = event.sequence
                    attempt = 0
                }
            } catch {
                task.cancel(with: .goingAway, reason: nil)
                attempt += 1
                let seconds = min(30.0, pow(2.0, Double(attempt))) + Double.random(in: 0...0.5)
                try? await Task.sleep(for: .seconds(seconds))            // backoff + jitter
            }
        }
        task?.cancel(with: .goingAway, reason: nil)
    }
}
// Android: collect live events only while the screen is at least STARTED
class OrderEventsClient(private val http: OkHttpClient, private val baseUrl: HttpUrl) {
    fun events(lastEventId: () -> String?): Flow<OrderEvent> = callbackFlow {
        val request = Request.Builder()
            .url(baseUrl.newBuilder().addPathSegments("orders/stream").build())
            .apply { lastEventId()?.let { header("Last-Event-ID", it) } }    // resume, don't miss
            .build()
        val source = EventSources.createFactory(http).newEventSource(request, object : EventSourceListener() {
            override fun onEvent(es: EventSource, id: String?, type: String?, data: String) {
                trySend(OrderEvent.parse(id, type, data))
            }
            override fun onFailure(es: EventSource, t: Throwable?, response: Response?) {
                close(t ?: IOException("stream closed"))                     // retried below
            }
        })
        awaitClose { source.cancel() }
    }.retryWhen { _, attempt ->
        delay(minOf(30_000L, 1_000L shl attempt.toInt().coerceAtMost(5)) + Random.nextLong(500))  // backoff + jitter
        true
    }
}

@Composable
fun OrdersScreen(viewModel: OrdersViewModel) {
    val lifecycle = LocalLifecycleOwner.current.lifecycle
    LaunchedEffect(lifecycle) {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {    // disconnects in the background
            viewModel.liveUpdates()
        }
    }
    /* list */
}
// Web: EventSource reconnects by itself and sends Last-Event-ID automatically
const source = new EventSource("/orders/stream", { withCredentials: true });

source.addEventListener("order-updated", (e) => {
  const order = JSON.parse((e as MessageEvent).data) as Order;
  upsertById(order);                                  // idempotent: replays are harmless
});

document.addEventListener("visibilitychange", () => {
  if (document.hidden) source.close();               // reopen (with the last id) when visible again
});
  • Resume from the last event: the app sends the id (SSE Last-Event-ID) or sequence number of the last update it applied, and the server sends everything after it. Reconnecting never creates a gap.
  • Backoff with jitter: after a failure, wait 1, 2, 4… seconds plus a little randomness, so a server restart isn’t followed by every phone reconnecting in the same second.
  • Only while visible: repeatOnLifecycle(STARTED) and SwiftUI’s .task stop the connection when the screen goes away and start it again when it returns.
  • Upsert by id: applying the same update twice has the same result as applying it once.

Production puzzles

Predict the output

The app reconnects its WebSocket when the network changes, subscribing to 'new orders from now on'. A tablet switches from Wi-Fi to LTE for 20 seconds during lunch rush. What does staff see?

Predict the output

An app keeps a WebSocket open in the background on iOS using a background task to 'stay up to date'. What actually happens?

Kotlin note

collectAsStateWithLifecycle() and repeatOnLifecycle(STARTED) stop collecting when the app goes to the background; with stateIn(WhileSubscribed(5_000)) upstream, the live connection closes 5 seconds later (surviving rotations) and reopens on return. That gives lifecycle-aware real-time updates without manual connect and disconnect code.

Merging live updates into a paged list

  • Updates to items already loaded: upsert by id in place; if the item’s sort key changed (status, time), move it or remove it if it no longer matches the filter.
  • New items that belong above the loaded range: if the user is at the top, insert them; if they’ve scrolled down, count them in a “3 new orders” banner that scrolls to the top and inserts on tap.
  • Items outside the loaded range: ignore them; they’ll arrive when that page loads.
  • With Paging 3 and Room: write events into Room; the PagingSource invalidates and the list updates, while LazyColumn keys keep the scroll position stable.
Exerciseeasy

Choose a channel

Pick a way to keep each screen up to date, and say why: (1) a weather screen; (2) a restaurant’s live order board on a tablet that’s always on; (3) a chat screen with typing indicators; (4) “your order is on its way” while the customer’s app is closed.

›

How fresh does it need to be, which direction does data flow, and is the app open?

SolutionTry it yourself first
  1. Polling every 15–30 minutes, or refresh when the screen opens. Weather changes slowly.
  2. Server-sent events (or a WebSocket) with resume from the last event id. Server to tablet only, needs seconds of freshness, and must not miss orders.
  3. WebSocket. Both sides send frequently (messages, typing, read receipts).
  4. Push notification. The app isn’t running; only the system can deliver it. When the user opens the app, it fetches the current status.
  • Channel by direction/frequency/scale: polling, long polling, SSE (HTTP-friendly, built-in Last-Event-ID), WebSockets (bidirectional, own heartbeats and resume), push for closed apps.
  • Correctness via sequence numbers or event ids: reconnect with the last applied, server replays from a retained log or forces a refetch.
  • Mobile lifecycle: connect only while visible (repeatOnLifecycle, .task, WhileSubscribed); background connections are suspended; use push instead.
  • Reconnect with exponential backoff and jitter; apply events idempotently (upsert, version checks).
  • Merge into paged lists without shifting content: in-place upserts, “N new” banners, ignore out-of-range items, or write to Room and let paging invalidate.

Check this part

  1. Which channel delivers updates when the app is closed?

  2. How does a client avoid missing events across reconnects?

  3. Why add jitter to reconnect backoff?

Pick the cheapest channel that meets the freshness need: polling, long polling, server-sent events, WebSockets, or push notifications when the app isn't open. On mobile, connect only while the screen is visible, reconnect with backoff, and resume from the last event id or sequence so nothing is missed. Merge updates by id, and show new items with a banner instead of shifting the list under the user.

Key takeaways

  • Load long lists in pages with a cursor, one request at a time.
  • Drop page responses that belong to an old filter or refresh.
  • Choose polling, SSE, WebSockets or push by how fresh and two-way it must be.
  • Reconnect with backoff and resume from the last update you applied.
  • Paging state with generations and cancellation; Paging 3 with Room for offline-first.
  • Resume protocols with sequence numbers make transports interchangeable and gaps impossible.
  • Lifecycle-aware connections; push when the app isn’t visible.
  • Merge live updates without moving content under the user.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.