SwiftQA Random

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 Task to 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 (via BGTaskScheduler) — 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

ScenarioMechanism
Long work, user waiting, in foregroundTask tied to ViewModel lifetime, Task.checkCancellation() in loops
Progress feedback neededCallback, AsyncStream, or Progress, hopping to @MainActor for UI
Continue if user navigates awayTask owned by a longer-lived service, not the transient screen
Must survive app backgrounding/terminationBackground URLSession for transfers, BGTaskScheduler for periodic/processing work
Interruption-prone (network, backgrounding)Chunked/resumable design, persisted progress state
Stalled/failed silentlyExplicit 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.”