← Back to list

Swift Concurrency Explained: Threads, Data Races, and Thread Safety (Part 1)

If you’ve been building iOS apps for a couple of years, you’ve likely worked with:

Teesma M · 2026-04-20 12:49 · 2 claps · 7.7 min read
#swift #ios-development #swift-concurrency #thread-safety #mobile-development
Open on Medium ↗
Wiki topics: SAF · Safety & Alignment 📱 · Mobile Development

Swift Concurrency Explained: Threads, Data Races, and Thread Safety (Part 1)

If you’ve been building iOS apps for a couple of years, you’ve likely worked with:

  • DispatchQueue
  • Completion handlers
  • Background threads for API calls

Most of the time, everything works… until it doesn’t.

You may have encountered issues like:

  • Random crashes
  • Inconsistent data or incorrect data
  • UI glitches that are difficult to reproduce

These issues usually come from one place:

Concurrency issues — especially data races and unsafe shared state.

This series is designed to help you understand how Swift Concurrency addresses these challenges. But before we dive into modern concepts like Actors and Sendable, it’s important to first understand the underlying problems that they solve.

In this Part 1, we’ll build a strong foundation by exploring threads, background execution, thread safety, and data races using practical examples.

Swift Concurrency? Swift Concurrency is Apple’s modern approach using async/await, Task (unstructured), and structured concurrency (async let, TaskGroup).

Instead of manually managing threads like this:

DispatchQueue.global().async {
    // work
    DispatchQueue.main.async {
        // UI update
    }
}

You write:

Task {
    let data = try await fetchData()
    updateUI(data)
}

It makes async code:

  • Easier to read
  • Safer to write
  • Less error-prone

But here’s the important part: Swift Concurrency is not just syntax — it’s about safe data access

1. What exactly is a Thread?

  • A thread is simply where your code runs.
  • It is an independent path of execution managed by the operating system.
  • Every iOS app runs at least one thread — the Main Thread — and can create additional threads for background work.

All threads in your app:

  • Share the same memory
  • Can access the same data
  • Run independently of each other

Threads in iOS -> In iOS, usually deal with two types of threads

Main Thread -> Used for UI updates

  • Handles user interactions
  • Must stay responsive

Background Threads -> Used for heavy tasks like

  • API calls
  • Data processing
  • File operations

API Call on Main Thread (Bad)

func fetchUser() {
    let url = URL(string: "https://api.example.com/user")!

    guard let data = try? Data(contentsOf: url) else {
        print("Failed to fetch data")
        return
    }

    print(data)
}

Problem:

  • This runs on the main thread
  • Data(contentsOf:) is a synchronous call
  • It blocks execution until the response is received

Result:

  • UI becomes unresponsive
  • App may freeze temporarily
  • Poor user experience

After Fix:

func fetchUser() {
    let url = URL(string: "https://api.example.com/user")!

    URLSession.shared.dataTask(with: url) { data, response, error in
        guard let data = data else { return }
        print(data)
    }.resume()
}

Why this works:

  • The network request runs in the background automatically
  • The main thread is not blocked
  • UI remains smooth and responsive
  • Any UI-related updates are safely moved back to the main thread

1.1. Main Thread This is responsible for everything the user sees and interacts with in an iOS app. It handles:

  • UI rendering
  • User interactions (taps, gestures, scrolling)
  • Updating views and animations

Because the UI runs on a single thread, anything that takes too long on the main thread directly affects how responsive the app feels.

Blocking the Main Thread

func fetchUser() {
    let url = URL(string: "https://api.example.com/user")!

    do {
        let data = try Data(contentsOf: url) // blocking call

        let name = String(data: data, encoding: .utf8) ?? "No Name"

        self.label.text = name

    } catch {
        print("Error:", error)
    }
}

In this example, the network call is executed synchronously using Data(contentsOf:)

What happens here:

  • The main thread is occupied until the request completes
  • The UI cannot update or respond to user actions during this time
  • The app may appear frozen or unresponsive

Result:

  • Frozen UI
  • Poor user experience
  • Delayed or ignored user interactions

After Fix (Using Asynchronous Call)

func fetchUser() {
    let url = URL(string: "https://api.example.com/user")!

    URLSession.shared.dataTask(with: url) { data, _, error in
        guard let data = data else { return }

        let name = String(data: data, encoding: .utf8) ?? "No Name"

        DispatchQueue.main.async {
            self.label.text = name
        }
    }.resume()
}

What changed:

  • The network request runs on a background thread
  • The main thread remains free to handle UI and user interactions
  • Once data is received, the UI is updated on the main thread

Blocking the main thread doesn’t just slow things down — it stops your app from responding entirely.

1.2. Background Thread Once we understand that blocking the main thread freezes the UI, the natural step is to move the work to a background thread.

This way, heavy tasks like network calls are executed without affecting the user interface.

Moving Work Off the Main Thread

func fetchUser() {
    let url = URL(string: "https://api.example.com/user")!

    DispatchQueue.global(qos: .userInitiated).async {
        guard let data = try? Data(contentsOf: url) else {
            print("Failed to fetch data")
            return
        }

        let name = String(data: data, encoding: .utf8) ?? "No Name"

        DispatchQueue.main.async {
            self.label.text = name
        }
    }
}

Why This Works:

  • The UI stays responsive because the Main Thread is no longer blocked by network or heavy operations.
  • The network request executes on a background thread, so it doesn’t interrupt user interactions.
  • UIKit updates are performed on the Main Thread, ensuring safe and predictable UI behavior.

2. Data races

  1. Two or more threads access the same memory
  2. At least one of them is writing
  3. There is no synchronization between themThis situation is known as a data race.

When we start using background threads, this becomes a real risk — multiple threads can access and modify the same data at the same time.

var users: [String] = []

func fetchUsers() {
    for i in 1...3 {
        DispatchQueue.global().async {
            let name = "User \(i)"
            users.append(name) // Data race
        }
    }
}

In this, each iteration creates an asynchronous task. All tasks run concurrently on background threads and attempt to modify the same users array.

Expected vs Actual

Expected -> [“User 1”, “User 2”, “User 3”] Actual -> unpredictable order / missing values

What’s Going Wrong

  • Multiple background threads are running at the same time
  • All of them try to modify the same Users array
  • There is no control over access

Result: Unpredictable behavior and inconsistent data

Why This is Dangerous:

  • Data may get lost or overwritten
  • Results can become inconsistent
  • Issues are difficult to reproduce and debug

Moving work to a background thread improves UI performance, but it also introduces the risk of unsafe access to shared data.

To handle this properly, we need to control how multiple threads access the same resource.

Tip: During development, enable Thread Sanitizer in Xcode to catch data races at runtime — including the one above. You can find it under Edit Scheme -> Diagnostics -> Thread Sanitizer.

Next -> To solve this problem, we need a concept called Thread Safety, which ensures that shared data is accessed in a controlled and predictable way.

Further reading -> https://www.avanderlee.com/swift/thread-sanitizer-data-races/

3. Thread Safety Thread safety is a way to control access to shared data when multiple threads are involved. It ensures that only one thread modifies the data at a time, preventing conflicts and inconsistent results.

Simple breakdown: Without Thread Safety: Thread A -> modifies data Thread B -> modifies data (same time)

With Thread Safety: Thread A -> modifies data Thread B -> waits Thread B -> modifies data

Fix 1: Using Serial Queue

var users: [String] = []
let queue = DispatchQueue(label: "users.queue")

func fetchUsers() {
    for i in 1...3 {
        queue.async {
            users.append("User \(i)") // Safe (one at a time)
        }
    }
}

Why This Works:

  • The custom queue is a serial queue
  • Only one task is executed at a time
  • All write operations to users are executed sequentially

Result: No overlapping writes → no data races

Fix 2: Using a Lock

var users: [String] = []
let lock = NSLock()

func fetchUsers() {
    for i in 1...3 {
        DispatchQueue.global().async {
            let name = "User \(i)"
            lock.lock()
            defer { lock.unlock() }
            users.append(name)
        }
    }
}

Why This Works:

  • lock() allows only one thread to enter the critical section at a time
  • Other threads wait until the lock is released
  • Once unlocked, the next waiting thread can proceed

Result: Controlled access to shared data -> safe updates

Multiple threads can still run concurrently, but thread safety mechanisms like a serial queue or a lock ensure that only one thread accesses shared data at a time.

This prevents data races and keeps the data consistent.

Further reading: https://swiftrocks.com/thread-safety-in-swift

4. Structured Concurrency Structured Concurrency is Swift’s way of running multiple async tasks in parallel, with a clear parent-child relationship — so when the parent finishes, all child tasks are guaranteed to be done too.

It uses:

  • async let -> for a fixed number of parallel tasks
  • TaskGroup / withTaskGroup -> for a dynamic number of parallel tasks

4.1 async let — Parallel Fixed Tasks

Without async let, tasks run one after another even though they don't depend on each other:

// Sequential — slower 
func loadDashboard() async throws {
    let user = try await fetchUser()     // waits to finish
    let posts = try await fetchPosts()  // only starts after user is done
}

With async let, both start at the same time:

// Parallel — faster
func loadDashboard() async throws {
    async let user = fetchUser()    // starts immediately
    async let posts = fetchPosts() // starts immediately, doesn't wait

    let (u, p) = try await (user, posts) // wait for both here
    print(u, p)
}
  • Both tasks launch at the same time
  • await only happens once at the end
  • If either throws, the other is automatically cancelled

4.2 TaskGroup — Parallel Dynamic Tasks

When you don’t know the number of tasks at compile time, use withTaskGroup:

func fetchAllUsers(ids: [Int]) async throws -> [String] {
    try await withThrowingTaskGroup(of: String.self) { group in
        // 1. Add tasks dynamically
        for id in ids {
            group.addTask {
                try await fetchUser(id: id)
            }
        }

        // 2. Collect results as they finish
        var results: [String] = []
        for try await name in group {
            results.append(name)
        }

        return results
    }
}
  • Tasks are created dynamically based on the ids array
  • All run in parallel
  • Results are collected as each one finishes
  • All child tasks are cancelled if the parent scope exits

Further reading -> https://www.donnywals.com/the-basics-of-structured-concurrency-in-swift-explained/

In Part 2, we’ll solve this problem using Actors, Data Isolation, and Sendable, which provide built-in safety for concurrent data access.

Stay tuned for Part 2 to understand how Swift enforces safe concurrency at the language level.


메타데이터
post_id
fff8cd8bedd0
slug
swift-concurrency-explained-threads-data-races-and-thread-safety-part-1-fff8cd8bedd0
url
https://medium.com/@teesma-dev/swift-concurrency-explained-threads-data-races-and-thread-safety-part-1-fff8cd8bedd0
canonical_url
https://medium.com/@teesma-dev/swift-concurrency-explained-threads-data-races-and-thread-safety-part-1-fff8cd8bedd0
author_url
https://medium.com/@teesma-dev
status
ok
fetched_at
2026-08-11 02:01:24