SwiftUI: Refreshable Task Cancellation
Understand the Refreshable Modifier
Pull-to-refresh became a standard interaction in iOS apps long ago, so .refreshable feels like one of the most natural SwiftUI APIs. You attach the modifier, await some async work, and SwiftUI handles the native refresh behavior for you.
Apple introduced .refreshable during the WWDC21 SwiftUI updates, in the same release cycle as Swift concurrency and the task modifier. In that session, Apple presented it as the modern way to support pull-to-refresh on iOS and iPadOS, while task was described as async work tied to a view’s lifetime and automatically cancelled when that view disappears.
And that lifecycle detail is exactly where things start to get interesting. But let’s get back to declarations.
What is.refreshable?
At its core, .refreshable gives a view an async refresh action:
List(items) { item in
Text(item.title)
}
.refreshable {
await reload()
}The system then connects that action to pull-to-refresh behavior where supported. Apple also notes that the action is propagated through the environment as a RefreshAction, which is why the feature integrates naturally with SwiftUI’s declarative model.
It feels like a callback, but it is more than that. It is SwiftUI-owned async work.
A Simple Example
Let’s show ScrollView with random items added with delay:
import SwiftUI
struct LoadedItem: Identifiable {
let id: Int
let value: Int
}
struct RefreshCancellView: View {
@State private var items: [LoadedItem] = []
var body: some View {
NavigationStack {
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 16) {
ForEach(items) { item in
Text(item.value, format: .number)
}
}
}
.padding(16)
.refreshable {
await loadItems(fromRefresh: true)
}
.task {
//Loading the initial list
await loadItems()
}
.navigationTitle("Refreshable Example")
.navigationBarTitleDisplayMode(.inline)
}
}
func loadItems(fromRefresh: Bool = false) async {
await MainActor.run { items.removeAll() }
let count = Int.random(in: 20...40)
for i in 0..<count {
try? await Task.sleep(for: .milliseconds(Int.random(in: 50...150)))
//Checking cancelaltion
if Task.isCancelled {
print("Cancelled fromRefresh: \(fromRefresh)")
return
}
let value = Int.random(in: 0...10_000)
let newItem = LoadedItem(id: i, value: value)
await MainActor.run {
items.append(newItem)
}
}
}
}On the surface, this looks reasonable. Clear the old values, load the new ones, append them as they arrive, and check for cancellation between steps.
But this code is actually a very good demonstration of the problem.
The Problem
The key trigger is this line:
await MainActor.run { items.removeAll() }That mutation happens immediately inside the .refreshable task.
Since items is @State, SwiftUI redraws the body right away. And because the refresh work is owned by SwiftUI, that redraw can invalidate the task context that is currently running the refresh closure. The Stack Overflow discussion describes exactly this: clearing the data causes the body to redraw, .refreshable is redrawn too, and the previous task gets cancelled.
And this could throw you into debugging session for a long of time…
Then the example makes the situation even more fragile here:
await MainActor.run {
items.append(newItem)
}Every append is another state mutation, which means another redraw opportunity while the refresh task is still active.
So this sample now has both failure multipliers:
one immediate redraw from
removeAll()repeated redraws from every
append
That is why it reproduces the issue so clearly.
Why This Happens
The simplest mental model is this:
.refreshable runs your closure as structured work owned by SwiftUI.
Apple’s WWDC explanation of the related task modifier is useful here: SwiftUI can attach async work to a view lifetime and cancel it when the view is removed. While Apple’s .refreshable docs are brief, the observed behavior lines up with that same lifecycle-driven model.
So when your refresh code changes the very state that drives the current body, SwiftUI may rebuild enough of the hierarchy to cancel the refresh-owned task before it completes. The Stack Overflow thread phrases it quite directly: clearing the observable collection redraws the body, .refreshable redraws too, and the previous task is cancelled.
This is also why the issue feels so strange. The code is not “wrong” in the usual sense. It is just colliding with the ownership model of SwiftUI concurrency.
A Common Symptom
You pull to refresh, the spinner appears, work starts, and then one of these happens:
the task stops halfway through
only part of the list loads
a network request suddenly throws cancellation (Code -999 for the those who are trying to Google it)
That half-finished feeling is often the first clue that the problem is not your API layer, but the view lifecycle interacting with structured concurrency.
Fix 1: Avoid intermediate UI changes until the final update
The cleanest fix is to avoid publishing intermediate mutations while the refresh is running.
Instead of clearing and appending directly into @State, collect results locally and assign once at the end:
func loadItems(fromRefresh: Bool = false) async {
let count = Int.random(in: 20...40)
var loaded: [LoadedItem] = []
for i in 0..<count {
try? await Task.sleep(for: .milliseconds(Int.random(in: 50...150)))
if Task.isCancelled {
print("Cancelled fromRefresh: \(fromRefresh)")
return
}
let value = Int.random(in: 0...10_000)
loaded.append(.init(id: i, value: value))
}
await MainActor.run {
items = loaded
}
}This works better because SwiftUI sees only one final state change instead of dozens of redraw-triggering mutations.
The tradeoff is that you lose progressive updates during refresh. But in most real pull-to-refresh flows, that is completely acceptable. The user usually cares more about a stable completion than about watching rows arrive one by one.
Fix 2: Move the Work Into an Unstructured Task
The other workaround is to let .refreshable create a separate task and await that task’s value:
.refreshable {
await Task {
await loadItems(fromRefresh: true)
}.value
}This pattern appears directly in one of the Stack Overflow answers: wrapping the logic in Task { ... }.value allows the refresh spinner to remain visible while the new task continues even if the original refresh-owned task is cancelled.
That makes it a practical fix when redraw-driven cancellation is getting in the way.
There is also a looser variation:
.refreshable {
Task {
await loadItems(fromRefresh: true)
}
}That decouples the work even more, but it also changes the semantics more aggressively because the refresh action no longer truly owns the work. The safer version is usually await Task { ... }.value, since it still lets the refresh indicator track completion.
Which Fix is Better?
In most cases, the first fix is the better design.
Use the single final update approach when:
you can build the refreshed data off to the side
progressive rendering is not important
you want to stay inside structured concurrency as much as possible
Use the unstructured task wrapper when:
redraw-driven cancellation is hard to avoid
intermediate observable changes are required
you need a practical workaround more than a pure structured model
The Stack Overflow thread includes both ideas in different forms: avoid redraw-triggering updates until later, or wrap the refresh body in a new task so it survives the redraw.
Final Thoughts
This is one of those SwiftUI behaviors that feels confusing until you frame it correctly.
If your .refreshable code updates the source of truth too early, the refresh task may cancel itself through the very redraw it caused. That is why the safest approach is often to keep all temporary work local and publish one final result when loading finishes.
And if you truly need incremental changes during refresh, then an unstructured task wrapper may be the most practical escape hatch.
That is a subtle detail, but it is the difference between a refresh flow that feels flaky and one that behaves predictably.
References
Apple Developer Documentation:
refreshable(action:)Apple Developer Documentation:
RefreshActionApple WWDC21 session “What’s new in SwiftUI,” including
refreshableand lifecycle-bound async tasks.
One More thing…
Through years of mentoring, I’ve built a small bookshelf of titles that are worth reading — or at least being aware of. The Grokking series from Manning Publishing is one of them. The style, the language, and the illustrations all combine to explain complex topics in an easy and engaging way.
You’ll find yourself wanting to learn more about things that once felt intimidating or difficult to understand. One of the latest additions is Grokking Data Structures by Marcello La Rocca.



