What the problem actually is

A screen looks simple.

It loads data when it appears, refreshes on pull, reloads after settings change, and sometimes restores state after navigation.

Then one day the same endpoint gets hit two, three, or four times for what product people think is “one screen open”.

That is usually not a networking problem. It is a lifecycle ownership problem.

The same fetch path gets triggered by multiple events that were never made mutually aware:

  • viewDidAppear
  • SwiftUI task or onAppear
  • pull to refresh
  • app foreground recovery
  • state restoration
  • notification-driven reloads

The backend sees duplicate requests. The user sees loading flicker, stale updates, and inconsistent state timing.

Why it keeps happening

Teams usually try to patch this too close to the request.

They add “if loading return” in one view model, then another code path bypasses it. They add a debounce, then a manual refresh becomes unreliable. They move the call to a different lifecycle method, then state restoration breaks.

The bug survives because the real issue is never stated clearly:

Who owns the right to start a fetch for this screen?

If the answer is “many things can call load()”, duplicate work is the expected outcome.

The implementation boundary that matters

The important boundary is not the HTTP client.

It is the screen-level request owner.

A screen should have one place that decides:

  • whether a fetch is already in flight
  • whether an existing fetch can satisfy the new request
  • whether the new trigger is a hard reload or a soft refresh
  • whether stale responses should still be applied

That means lifecycle events should not each contain their own fetch logic.

They should only emit a reload reason into one owner.

A concrete pattern to fix it

The pattern I trust is:

  1. One request owner for the screen.
  2. An explicit ReloadReason enum.
  3. One in-flight task reference.
  4. Request coalescing for equivalent reasons.
  5. A response token so stale results do not overwrite newer state.

This is simplified pseudocode, not production code.

import Foundation

enum ReloadReason: Equatable {
    case firstDisplay
    case pullToRefresh
    case foregroundResume
    case settingsChanged

    var allowsCoalescing: Bool {
        switch self {
        case .firstDisplay, .foregroundResume:
            return true
        case .pullToRefresh, .settingsChanged:
            return false
        }
    }
}

final class ScreenDataLoader {
    private var inFlightTask: Task<Void, Never>?
    private var lastAppliedToken = UUID()

    func load(reason: ReloadReason) {
        if let inFlightTask, reason.allowsCoalescing {
            return
        }

        inFlightTask?.cancel()
        let token = UUID()

        inFlightTask = Task { [weak self] in
            defer { self?.inFlightTask = nil }

            let payload = try? await self?.fetchPayload()
            guard !Task.isCancelled else { return }
            guard let self, self.lastAppliedToken != token else { return }

            self.lastAppliedToken = token
            self.apply(payload)
        }
    }

    private func fetchPayload() async throws -> Payload {
        // Network request and mapping.
    }

    private func apply(_ payload: Payload?) {
        // Update visible state on the main actor.
    }
}

The screen can still trigger load(reason:) from many places.

The difference is that those triggers are now requests to a single decision point, not direct fetch execution.

How to verify the fix

Do not stop at “the request count looked better once.”

Verify it with repeatable checks:

  • open the screen from a cold start
  • navigate away and back quickly
  • trigger pull to refresh during first load
  • background the app mid-request, then foreground it
  • restore the screen from an interrupted flow
  • inspect request count, response application count, and visible loading transitions

The goal is not only fewer requests.

The goal is one visible user action producing one intentional loading contract.

What still goes wrong in production

Three failure modes still show up often.

First, teams gate only the transport layer and forget stale UI application. That removes duplicate traffic but still allows old results to overwrite new state.

Second, every reload reason gets treated the same. A foreground resume is not the same as pull to refresh. A settings change is not the same as first display.

Third, two different owners still exist. A view model owns one path, and a coordinator or parent container owns another.

If there is more than one authority over screen loading, duplicate network calls are still just waiting for the next feature to expose them.

The fix is not “move the request somewhere else”.

The fix is making screen loading a single owned behavior with explicit reasons, coalescing rules, and stale-result protection.