Vedran Burojevic
← WritingSeptember 30, 2026

Network failure testing: offline, latency, retries, and cancellation

How to test offline behavior, slow networks, retries, cancellation, and partial responses so network failures become product states instead of surprises.

On this page

Network failures are not edge cases in a mobile app. They are normal runtime conditions with worse timing.

The user opens the app in an elevator. Wi-Fi says it is connected but DNS is having a small career crisis. A request succeeds after the user has already left the screen. A retry lands after a newer response. A server returns half the data the UI expected. The app is not broken because the network failed. The app is broken when it treats failure as surprising.

Good network failure testing turns those conditions into product states: offline, delayed, retrying, cancelled, partial, stale, unavailable, recovered. Once the states exist, the UI can explain them, the model can test them, and release confidence no longer depends on hoping cellular physics behaves professionally.

1. Start with the failure contract

Before writing test helpers, decide what the product should do when the network is hostile.

For each network-backed flow, write down the contract:

Flow: Search recipes
 
If the device is offline:
- show cached results if available
- show an offline message above the list
- keep the search field editable
- do not show a full-screen fatal error
 
If the request is slow:
- show progress after 300 ms
- keep prior results visible until replacement data arrives
- allow cancellation when the query changes
 
If the request fails after retry:
- keep prior results
- show a recoverable error
- expose a retry action
 
If a newer query starts:
- cancel or ignore older results
- never replace newer results with stale data

That contract matters more than the transport detail. URLSession errors, HTTP status codes, timeout policies, and retry strategies are implementation inputs. The user sees product behavior.

Without a failure contract, tests usually collapse into shallow assertions like “throws an error” or “shows alert.” That is not enough. A mature app needs to know whether the screen preserves data, whether the action remains available, whether cancellation is respected, and whether the error state can recover.

2. Push networking behind a small boundary

Network failure tests are painful when every feature reaches directly into URLSession.shared.

A better shape is a small client boundary with explicit operations:

protocol RecipeService: Sendable {
    func searchRecipes(query: String) async throws -> [RecipeSummary]
    func recipeDetail(id: Recipe.ID) async throws -> RecipeDetail
}

The feature should depend on RecipeService, not on raw request construction. Lower-level networking can still be tested separately with a URLProtocol stub or local server. Feature tests should not need to know whether the endpoint used query parameters, headers, or a custom decoder.

That separation gives you two useful test layers:

  1. Transport tests verify request construction, status handling, decoding, authentication, and retry policy.
  2. Feature tests verify product behavior when operations succeed, fail, slow down, cancel, or return partial data.

If the only test seam is “mock the exact URL,” every redesign of the API surface breaks feature tests. That is test coupling wearing a networking costume.

3. Test offline as a state, not only as an error

Offline behavior is not just URLError.notConnectedToInternet.

The app may know it is offline before a request starts. It may discover offline during a request. It may have cached data. It may have no cached data. It may have pending mutations waiting for sync. Those are different product states.

A useful model test might look like this:

@Test
func offlineSearchKeepsCachedResultsVisible() async {
    let service = StubRecipeService(
        searchResult: .failure(URLError(.notConnectedToInternet))
    )
    let store = RecipeSearchModel(
        service: service,
        cachedResults: [.sample(title: "Pasta")]
    )
 
    await store.search("pasta")
 
    #expect(store.results.map(\.title) == ["Pasta"])
    #expect(store.banner == .offline)
    #expect(store.isRetryAvailable)
}

The assertion is not that URLError exists. The assertion is that the app preserved useful work and exposed a recovery path.

Use reachability signals carefully. NWPathMonitor can help the app avoid obviously doomed work, but it is not proof that the next request will succeed. Captive portals, DNS failures, server outages, and bad proxies all exist because the universe enjoys distributed systems comedy.

Inject reachability as a hint:

enum ConnectivityState: Sendable {
    case satisfied
    case unsatisfied
    case expensive
    case constrained
}
 
protocol ConnectivityMonitor: Sendable {
    var currentState: ConnectivityState { get async }
}

Then test how the feature behaves when the hint changes. Do not make reachability the only source of truth. The request result still wins.

4. Make latency deterministic

Slow networks expose bugs that fast mocks hide.

The common mistake is using Task.sleep directly inside tests and hoping timing remains stable. That produces flaky tests and the faint smell of a haunted CI lane.

Prefer injecting a clock or a controllable async gate into the code that owns delays, progress thresholds, debounce behavior, and retry backoff.

struct RetryPolicy: Sendable {
    var maxAttempts: Int
    var delay: @Sendable (Int) -> Duration
}

For feature behavior, use a service stub that can suspend until the test releases it:

final actor SuspendedRecipeService: RecipeService {
    private var continuation: CheckedContinuation<[RecipeSummary], Error>?
 
    func searchRecipes(query: String) async throws -> [RecipeSummary] {
        try await withCheckedThrowingContinuation { continuation in
            self.continuation = continuation
        }
    }
 
    func resume(with result: Result<[RecipeSummary], Error>) {
        continuation?.resume(with: result)
        continuation = nil
    }
}

Now the test can verify intermediate states without racing the scheduler:

@Test
func slowSearchKeepsPreviousResultsUntilNewResultsArrive() async throws {
    let service = SuspendedRecipeService()
    let model = RecipeSearchModel(
        service: service,
        initialResults: [.sample(title: "Old result")]
    )
 
    let task = Task { await model.search("new") }
    await model.waitUntilRequestIsInFlight()
 
    #expect(model.results.map(\.title) == ["Old result"])
    #expect(model.isLoading)
 
    await service.resume(with: .success([.sample(title: "New result")]))
    await task.value
 
    #expect(model.results.map(\.title) == ["New result"])
    #expect(!model.isLoading)
}

The important part is not this exact helper. The important part is control. If the test cannot pause the request at the interesting moment, it cannot prove the UI behaves well during the delay.

5. Verify retry policy without waiting for real time

Retries need tests because they are where apps quietly become rude.

A retry policy should answer:

  • which failures are retryable
  • how many attempts are allowed
  • how backoff is calculated
  • whether cancellation stops the retry loop
  • whether user-initiated retry differs from automatic retry
  • whether non-idempotent requests are protected from duplicate side effects

A good transport-level test records attempts and advances a test clock instead of waiting for wall time:

@Test
func retryStopsAfterConfiguredAttempts() async throws {
    let transport = RecordingTransport(
        results: [
            .failure(URLError(.timedOut)),
            .failure(URLError(.timedOut)),
            .failure(URLError(.timedOut))
        ]
    )
    let client = APIClient(
        transport: transport,
        retryPolicy: .init(maxAttempts: 3, delay: { _ in .seconds(1) })
    )
 
    await #expect(throws: URLError.self) {
        try await client.getRecipes()
    }
 
    #expect(await transport.requestCount == 3)
}

Also test what should not retry:

  • validation errors
  • authentication failures that need user action
  • 404 for a specific resource
  • explicit cancellation
  • mutations without an idempotency key

Blind retry is just denial in a loop. The server will notice.

6. Treat cancellation as correctness

Cancellation bugs are common in search, autocomplete, scrolling feeds, detail screens, and any flow where the user can move faster than the network.

The product rule is usually simple: older work must not overwrite newer intent.

For search, test the stale response case explicitly:

@Test
func olderSearchResponseDoesNotReplaceNewerResults() async {
    let service = OrderedSearchService()
    let model = RecipeSearchModel(service: service)
 
    let first = Task { await model.search("p") }
    await service.waitForRequest("p")
 
    let second = Task { await model.search("pizza") }
    await service.waitForRequest("pizza")
 
    await service.complete(query: "pizza", with: [.sample(title: "Pizza")])
    await service.complete(query: "p", with: [.sample(title: "Pasta")])
 
    await first.value
    await second.value
 
    #expect(model.results.map(\.title) == ["Pizza"])
}

The implementation might cancel the first task. It might track a request token and ignore late responses. Either is acceptable if the behavior is correct and the boundary is clear.

Also test teardown paths:

  • leaving the screen cancels work that only existed for that screen
  • starting a new query cancels or supersedes the old query
  • tapping Cancel stops progress and prevents later state writes
  • cancelled requests are not shown as user-facing errors

A cancelled request is not a failure the user needs to hear about. It is the app respecting the fact that the user moved on.

7. Test partial responses and degraded data

Not every bad response is a thrown error.

Real systems return partial data, empty arrays, missing optional fields, stale cache entries, paginated results with a failed next page, and success responses that contain domain-level warnings. If the app only tests success and failure, it misses the uncomfortable middle where many production bugs live.

Create fixtures for degraded but valid responses:

Fixtures:
- search_success_full.json
- search_success_empty.json
- search_success_partial_missing_images.json
- search_success_with_deprecated_category.json
- search_next_page_failure.json
- detail_success_missing_optional_sections.json

Then decide the behavior:

  • Can the UI render a row without an image?
  • Does an empty result mean “no matches” or “filter invalid”?
  • Does a failed next page preserve already loaded items?
  • Can stale cached data be labeled clearly?
  • Are domain warnings visible anywhere useful?

This is where product and engineering need the same vocabulary. “The request succeeded” is not a product state. “The list is showing complete current results” is a product state. “The list is showing cached results because refresh failed” is another.

8. Use the right tool for each layer

One test mechanism will not cover the whole failure surface.

Use a layered setup:

  • Pure model tests for offline, retry, cancellation, stale response, and partial-data behavior.
  • Transport tests with a custom URLProtocol or local server for status codes, headers, decoding, and request construction.
  • UI tests for the visible contract: loading, offline banners, retry buttons, stale-data labels, and preserved content.
  • Manual device checks with Network Link Conditioner or a proxy when latency, radio changes, captive portals, or background behavior matters.
  • Observability checks to make sure failures are logged with enough context to diagnose production issues.

For iOS apps, I like keeping most failure behavior in fast model tests. UI tests should prove that the critical states are visible and actionable, not exhaust every transport permutation. Transport tests should prove the network client maps reality into useful domain results. Manual device tests should be reserved for conditions the simulator cannot honestly represent.

That split keeps the suite fast without pretending mocks are the same thing as a phone on bad hotel Wi-Fi.

9. Put failure scenarios into the release checklist

Network failure testing should not depend on someone remembering to be pessimistic near the end of a release.

Add a short checklist to the feature or release process:

Network behavior checked:
- offline with cached data
- offline without cached data
- slow first response
- retryable timeout
- non-retryable server response
- cancellation after leaving screen
- stale response after newer request
- partial response / missing optional data
- failed pagination after existing items
- recovery after connection returns

Not every feature needs every scenario. A read-only marketing screen and a paid checkout flow do not deserve equal ceremony. But the critical paths should name their failure states before production users do it for you with one-star punctuation.

The point is not to make networking perfect. The point is to make failure boring: visible, recoverable, measured, and tested. Mobile networks will keep being mobile networks. The app can still behave like an adult.

Command menu

Navigate the site or run an action