How do you handle long-running tasks?
“Long-running” spans a few different problems depending on what “long” means and whether the app stays foregrounded.
1. Structured concurrency as the default shape
@MainActor
final class UploadViewModel: ObservableObject {
@Published var progress: Double = 0
private var uploadTask: Task<Void, Error>?
func startUpload(_ data: Data) {
uploadTask = Task {
try await uploadService.upload(data) { [weak self] progress in
await MainActor.run { self?.progress = progress }
}
}
}
func cancelUpload() { uploadTask?.cancel() }
deinit { uploadTask?.cancel() }
}
Storing the Task handle explicitly is what lets you cancel it deliberately.
2. Cooperative cancellation — checking, not assuming
func processLargeDataset(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation()
await process(item)
}
}
Forgetting this is a common bug: cancelling a Task wrapping a tight synchronous loop does nothing until it hits an await or an explicit check.
3. Progress reporting back to the UI
func uploadProgress(_ data: Data) -> AsyncStream<Double> {
AsyncStream { continuation in
Task {
// continuation.yield(fraction) as work proceeds
continuation.finish()
}
}
}
4. Handling the “user navigates away mid-task” case deliberately
Should the task continue if the user leaves the screen, or should it be cancelled? This should be explicit:
- Cancel on dismissal — appropriate for work only relevant to that screen; tie the
Taskto the view/ViewModel’s lifetime, or SwiftUI’s.task { }modifier which auto-cancels. - Continue in the background — appropriate for user-initiated actions with real consequences; the task needs to live in a longer-lived owner, not the transient screen’s ViewModel.
5. Surviving app backgrounding — BGTaskScheduler / background modes
BGAppRefreshTask/BGProcessingTask(viaBGTaskScheduler) — for periodic background refresh or longer processing tasks the OS schedules opportunistically.- Background URLSession — for uploads/downloads that need to continue even if the app is suspended or terminated; the OS manages the transfer and relaunches your app via delegate callback.
beginBackgroundTask(withName:expirationHandler:)— a short grace period to finish critical wrap-up work.
Trying to keep a plain Task running indefinitely in the background by fighting the app lifecycle is the wrong approach.
6. Retry and resumability for interrupted long-running work
Long-running work is more likely to be interrupted, so designing for resumability matters:
- Chunked/resumable uploads — resume from the last successful chunk rather than restarting.
- Persisting task state to disk so an interrupted export/processing job can resume.
7. Timeout and user-facing failure handling
A long-running task needs an explicit timeout policy and a clear, actionable failure state distinguishable from “still working” — a progress UI that silently stalls forever with no timeout or error surfaced is a common, avoidable UX failure.
8. Sendable/actor considerations for the work itself
If the long-running work touches shared mutable state, the same Sendable/actor isolation discipline applies — long-running tasks are exactly the kind of thing that tends to run concurrently with other work.
Interview-ready summary
| Scenario | Mechanism |
|---|---|
| Long work, user waiting, in foreground | Task tied to ViewModel lifetime, Task.checkCancellation() in loops |
| Progress feedback needed | Callback, AsyncStream, or Progress, hopping to @MainActor for UI |
| Continue if user navigates away | Task owned by a longer-lived service, not the transient screen |
| Must survive app backgrounding/termination | Background URLSession for transfers, BGTaskScheduler for periodic/processing work |
| Interruption-prone (network, backgrounding) | Chunked/resumable design, persisted progress state |
| Stalled/failed silently | Explicit timeout policy + distinguishable failure UI state |
One-liner if pressed: “For foreground long-running work I wrap it in a Task explicitly owned by whatever should control its lifetime, add explicit cancellation checks for CPU-bound loops since cancellation is cooperative, and report progress back through an AsyncStream or callback hopping to @MainActor. The genuinely different case is surviving app backgrounding or termination — that needs background URLSession for transfers or BGTaskScheduler for periodic work, since iOS deliberately doesn’t let a plain Task run unbounded once the app is suspended, and for anything interruption-prone I’d design for resumability rather than assuming it’ll run to completion uninterrupted.”