← All topics/Concurrency

Practical interview questions

Scenario-style prompts with sample answer outlines. Focus is on how you would design and reason in real codebases.

Question 7

Debugging concurrency bugs

A feature has intermittent crashes or wrong UI state under load. How do you debug suspected concurrency issues before and after they reach production?

Answer outline

Think of debugging in two phases: before and after production.

Before production — reach for these two tools first:

(1) Thread Sanitizer (TSan) — enable it in your scheme's Diagnostics tab. TSan turns intermittent races into deterministic crashes with a file/line stack.

(2) Strict concurrency — ramp the Swift concurrency checking setting toward complete; violations surface as compiler warnings before anything ships.

Reproducing intermittents: run the flaky path in a tight loop or UI test, and sprinkle Task.sleep jitter at suspensions to widen timing windows. Use MainActor.assertIsolated() in debug builds to catch UI updates that sneak in from the wrong context.

After production: symbolicating EXC_BAD_ACCESS crashes near objc_msgSend usually means a deallocated object — look for a missing weak self or a task that outlived its view. Impossible UI state under load is usually one of two things:

(1) Missing identity guard — a stale result from an older request overwrote the UI.

(2) Actor reentrancy — state changed across an await inside the actor; you acted on stale values after resuming.

Left: stale result without identity guard. Right: actor reentrancy — Task B enters while Task A is suspended.

Principles

  • TSan first — it turns a race that happens 1-in-10 runs into a guaranteed crash with a stack trace.
  • Wrong image in a cell → missing cancel + identity guard. UI updated from background → missing @MainActor / await MainActor.run.
  • Impossible state after await inside an actor → reentrancy. Another task mutated state while you were suspended — re-check invariants when you resume.
  • Strict concurrency is a free pre-flight check — the more violations you fix at compile time, the fewer surprises at runtime.
Bug 1 — missing identity guard (stale result overwrites UI)
// ❌ No guard — Task A can overwrite Task B's correct result
func search(_ query: String) {
    searchTask?.cancel()
    searchTask = Task {
        let items = try? await api.search(query)
        results = items ?? []  // stale if a newer task already set results
    }
}

// ✅ Generation guard — discard result if a newer task has run
func search(_ query: String) {
    searchTask?.cancel()
    generation += 1
    let gen = generation
    searchTask = Task {
        let items = try? await api.search(query)
        guard gen == generation else { return }  // drop stale result
        results = items ?? []
    }
}
Bug 2 — actor reentrancy (Task B mutates state while Task A is suspended)
// ❌ Doesn't re-check after await — Task B may have stored data already
actor ImageCache {
    private var store: [URL: Data] = [:]

    func image(for url: URL) async throws -> Data {
        if let hit = store[url] { return hit }
        let data = try await download(url)  // Task A suspends here — actor is free
        // Task B may have entered and stored data while Task A was suspended
        store[url] = data                   // duplicate download + overwrite
        return data
    }
}

// ✅ Re-check after every await before writing
func image(for url: URL) async throws -> Data {
    if let hit = store[url] { return hit }
    let data = try await download(url)
    if let existing = store[url] { return existing }  // re-check after await
    store[url] = data
    return data
}
Debug-only main-thread assertion
func updateLabel(_ text: String) {
    MainActor.assertIsolated()
    label.text = text
}

Follow-up angles

  • Swift 6 language mode turns many concurrency diagnostics into errors.