swift · tutorial · concurrency

Task Cancellation in Infinite Lists

Published August 17, 2026 · 5 min read · intermediate

Goal

By the end of this tutorial, you will build a scrolling SwiftUI list where each row loads its own details and cancels work cleanly when the row scrolls away.

State it explicitly:

“Show a feed where rows can load details on demand without keeping useless tasks alive.”

Requirements

  • Basic SwiftUI knowledge
  • Xcode 15+
  • iOS 17+

The use case

Infinite lists often load lightweight preview data first, then fetch richer information when a row becomes visible. If the user scrolls quickly, some rows disappear before the fetch finishes. That is normal. What is not normal is letting those off-screen rows keep doing work that no longer matters.

Step 1 - The simplest working version

Start with a list that loads details when each row appears.

import SwiftUI

struct FeedItem: Identifiable {
    let id: UUID
    let title: String
}

struct FeedScreen: View {
    let items: [FeedItem]

    var body: some View {
        List(items) { item in
            FeedRow(item: item)
        }
    }
}

struct FeedRow: View {
    let item: FeedItem
    @State private var detail: String = "Loading..."

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(item.title)
                .font(.headline)
            Text(detail)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .task {
            detail = await loadDetail()
        }
    }
}

This works, but it has a problem: the task is not tied to the row’s identity. If the row gets reused or the view changes, the work may not behave the way you expect.

Quick verification: Run the list and confirm each row can show its own detail text.

Step 2 - Make it reusable

Now tie the async work to the specific item.

struct FeedRow: View {
    let item: FeedItem
    @State private var detail: String = "Loading..."

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(item.title)
                .font(.headline)
            Text(detail)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .task(id: item.id) {
            detail = await loadDetail()
        }
    }
}

What changed: The task now restarts when the row’s identity changes. That keeps the async work attached to the correct row instead of a stale view instance.

The row is still reusable because it only needs an item and some local display state.

Quick verification: Swap the list data or trigger a refresh and confirm each row still shows the right detail.

Step 3 - SwiftUI-specific refinement

Add cancellation awareness so rows stop doing work when they are no longer visible.

struct FeedRow: View {
    let item: FeedItem
    @State private var detail: String = "Loading..."

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(item.title)
                .font(.headline)
            Text(detail)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .task(id: item.id) {
            do {
                try Task.checkCancellation()
                detail = try await loadDetail()
            } catch {
                detail = "Unavailable"
            }
        }
    }
}

You can also make the loading function clearly asynchronous and cancel-friendly:

func loadDetail() async throws -> String {
    try await Task.sleep(for: .seconds(1))
    return "Loaded detail"
}

The important part is not the fake network delay. It is the fact that the task is tied to row identity and cancellation can stop useless work.

Quick verification: Scroll quickly so several rows disappear before loading completes, then confirm the app does not keep updating rows that are no longer visible.

Result

You now have a list that is:

  • responsive while scrolling
  • safe for row-level async loading
  • aligned with SwiftUI identity and cancellation behavior
  • easier to reason about under fast user interaction

That matters because infinite lists are one of the places where async work and UI reuse collide the most.

The same pattern also scales to richer row content. If a row needs an avatar, a subtitle, or a short remote summary, the task can still stay local as long as the identity is stable. That keeps the list responsive without moving all loading logic into one giant screen model.

Common SwiftUI pitfalls

  • Mistake: Starting the fetch in onAppear and assuming it runs once
    Why it happens: onAppear feels lifecycle-like
    How to fix it: tie the work to task(id:) and make the identity explicit

  • Mistake: Letting every row keep a task alive after it scrolls away
    Why it happens: the async work is not cancellation-aware
    How to fix it: use task, check cancellation, and keep the row state local

  • Mistake: Storing loaded detail in a shared object for all rows
    Why it happens: centralizing state looks simpler at first
    How to fix it: keep row-local state local unless multiple screens need it

  • Mistake: Forgetting that cancellation is part of the design Why it happens: the task works in the happy path, so cancellation gets ignored How to fix it: test fast scrolling and refreshes, then confirm old work stops instead of updating stale rows

When NOT to use this

If the data should be loaded once for the whole screen, do not start one task per row. Load it at the screen boundary and pass the result down.

Takeaway

If you remember one thing: Task cancellation works best when the task belongs to the identity that needs the data.

Next steps

If you want to keep async work and list identity aligned, these are the best follow-ups: