Test iOS Pagination Races and Duplicate Rows

Test iOS Pagination Races and Duplicate Rows

An iOS user quickly changes the filters on a list. Page two of the old query is still in flight, while page one of the new query is already visible. Seconds later, the old response arrives and unrelated items appear in the list. This is hard to reproduce on a stable network. Repeatedly scrolling on a cloud Mac cannot guarantee that two requests return in the order needed to expose the bug, either. A more reliable approach is to define the pagination write rules as a state model, test them with responses delivered in a fixed order, and only then connect the model to the UI.

Define how the list is allowed to change

The example below uses page numbers starting at 1 and integer business IDs. A real API may return other fields, but the model needs to distinguish the current query, the page awaiting a response, the IDs already displayed, and whether the server has another page. Every filter change starts a new query. Even if the old and new queries return the same items, an old request must not write to the new list.

Assign each query an increasing generation, and include it with the page number in a request ticket. A response may write to the list only if its ticket exactly matches the request currently awaiting a response. If another scroll-to-bottom event occurs while a request is in flight, ignore it rather than sending two requests for the same page.

Canceling a request saves network and compute resources; it does not prove that the old callback will never run. Check the request ticket before writing to the list.

Use a runnable model to test three response sequences

Save the following as Pager.swift and run xcrun swift Pager.swift on a Mac with the Xcode command-line tools installed. The code uses neither networking nor timers, so every run exercises the same failure sequences.

struct Ticket: Equatable {
    let generation: Int
    let page: Int
}

struct Pager {
    private(set) var generation = 0
    private(set) var nextPage = 1
    private(set) var ids: [Int] = []
    private(set) var inFlight: Ticket?
    private(set) var hasMore = true

    mutating func start() -> Ticket? {
        guard hasMore, inFlight == nil else { return nil }
        let ticket = Ticket(generation: generation, page: nextPage)
        inFlight = ticket
        return ticket
    }

    mutating func reset() {
        generation += 1
        nextPage = 1
        ids = []
        inFlight = nil
        hasMore = true
    }

    mutating func finish(
        _ ticket: Ticket,
        rows: [Int],
        hasMore: Bool
    ) {
        guard inFlight == ticket else { return }
        var seen = Set(ids)
        for id in rows where seen.insert(id).inserted {
            ids.append(id)
        }
        nextPage += 1
        self.hasMore = hasMore
        inFlight = nil
    }
}

var pager = Pager()
let page1 = pager.start()!
assert(pager.start() == nil)
pager.finish(page1, rows: [1, 2], hasMore: true)

let page2 = pager.start()!
pager.finish(page2, rows: [2, 3], hasMore: true)
pager.finish(page2, rows: [2, 3], hasMore: true)
assert(pager.ids == [1, 2, 3])

let oldPage = pager.start()!
pager.reset()
let newPage = pager.start()!
pager.finish(oldPage, rows: [4], hasMore: false)
assert(pager.ids.isEmpty)
pager.finish(newPage, rows: [7], hasMore: false)
assert(pager.ids == [7])
assert(pager.start() == nil)

The first assertion checks that repeated scroll-to-bottom events start only one load. The second group covers both an ID repeated across pages and a callback invoked twice: item 2 appears only once, and a completed page cannot be accepted again. The final group simulates a filter change. The old page arrives with an old generation and must not change the new list or its hasMore state.

Preserve the write boundary when integrating real requests

The UI layer should only emit “load next page” and “reset query” events. When a request starts, retain the ticket returned by start(). When it finishes, pass its response to finish, then read the list from the model. You can also cancel the old network task on reset, but do not let its completion callback bypass the model and append directly to the UI array.

Two details need to match your API. First, the example deduplicates by ID. If the API has no stable ID, define what makes a record the same record; array position is not a substitute. Second, the example advances by page number. For a cursor-based API, put the cursor used for the request in the ticket, then save the new cursor returned by the response after accepting it. If the new cursor equals the old one while the API still claims there is another page, record a protocol error and stop automatic loading to avoid requesting the same page repeatedly.

Where errors and retries belong

The example covers only successful responses. In a real implementation, failure handling must also check the ticket: an error from an old request must not overwrite the loading state of a new query. When the current page fails, clear its in-flight marker without advancing the page number; a user retry should request that same page. Show loading, failure, and retry availability separately in the UI, and do not clear existing items because of one timeout.

Run the tests in a stable environment

Run the model above on your development machine first to verify the state rules, then move the same sequences into your project tests. The test double should let test code decide when to deliver each page response instead of using short delays to guess request order. Next, check the UI in the iOS simulator: the loading indicator appears only once after repeated scroll-to-bottom events, old items do not flash back after a filter change, and reaching the last page does not trigger another request.

Remote desktop latency may make the problem easier to observe, but the tests must not depend on it. A cloud Mac lets the team rerun these assertions with the same toolchain and simulator environment; the tests still control response order. Whenever the list implementation changes, keep four test cases: repeated scroll-to-bottom events, duplicates across pages, a late old response, and retry after failure.

Review every write path before merging

Finally, trace the call chain to find every place that writes to the array. Check completion callbacks, pull-to-refresh, filter changes, and view restoration to confirm that they all pass through the same ticket validation. Otherwise, the model tests may pass while a callback on a separate path still contaminates the list. Also confirm that deduplication does not silently change the server’s ordering, and that the last-page state comes from an accepted response, not whichever request happens to return first.

You do not need to wait for a poor network connection to uncover a pagination race. Once “which response may change the list” is an executable rule, out-of-order responses become an ordinary regression test rather than an intermittent problem captured in a production screenshot.

Frequently asked questions

Is cancelling a request enough to prevent stale list updates?

No. Check the response’s query generation and pending page before applying it, even when the previous task was cancelled.

Should the client silently discard duplicate page records?

Deduplicate by stable business ID, but also verify that the server’s page or cursor advances so a repeated page does not create an endless loading loop.

Dedicated physical machine

Get a Mac ready for your next remote development session

Choose a model and location for your needs, then connect via Screen Sharing, VNC, or SSH. Check the console for current pricing and availability.

Configure a Remote Mac