This article is also published on Medium.
Read it there: iOS Threads and Memory Management
Threads
Let’s start with threads. If you ever tried running an app from Xcode, you heard about the Main thread. So, what are threads, how many of them are there, and how do we use them?
A thread is a single sequential flow of control within a program, which means that each process has at least one thread. In iOS, the primary thread on which the process is started is commonly referred to as the Main thread. This is the thread in which all UI elements are created and managed. All interrupts related to user interaction are ultimately dispatched to the UI thread where the handler code is written — your IBAction methods are all executed on the main thread.
The real thing is knowing how to use multiple threads simultaneously, which is called concurrent programming. This is basically a fancy way of saying “running multiple tasks at the same time.”
The first question that pops in my mind is: can’t we just run multiple tasks on a single thread? Sure, but that’s not always a good solution. Small tasks in small numbers can be run together thanks to modern hardware. When it comes to more complex tasks, you should use background threads and see that fancy concurrent programming in action.
If we run two tasks at the same time on the main thread, they would be done using a “criss-cross” technique: the thread completes part of the first task, then part of the second, then back to the first, and so on, until one of them is completed.
Here’s a simple example to visualize:
We have two mathematical operations (tasks) to complete. The first one is 1+2+3, and the second one is 4+5+6. Let's say a thread is a math teacher that shows us the result of these two operations. The thread begins with 1+2+3 and completes the first part, which is 1+2. After that, it solves the first part of the second task, which is 4+5. Then it goes back to the first one and solves the second part, which is now 3+3 ([1+2 = 3 -> 3+3]). At this moment the first task is completed, and the thread proceeds to resolve the second task.
Using concurrent programming
How do we use concurrent programming? It’s pretty simple: we can use two threads instead of just one. Here’s a simple example in code:
import UIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
DispatchQueue.global(qos: .userInitiated).async {
let result1 = self.performTask1()
print("This runs on a background queue. result1 = \(result1)")
DispatchQueue.main.async {
let result2 = self.performTask2()
print("This runs on the main queue after the outer block. result2 = \(result2)")
}
}
}
func performTask1() -> Int { 1 + 2 + 3 }
func performTask2() -> Int { 4 + 5 + 6 }
}
Here we’re using a background thread as another math teacher that solves one operation, while the Main thread solves the other.
In our example, we’re using the background thread to solve a simple operation. Background threads are usually used when you have to download an image, make an API call, or perform a task that will take some time.
The question is: how do we know when the task is completed? Swift provides closures (completion handlers). Closures are a chunk of code that provides further instructions on what to do after the initial task is done.
You’ve likely seen this when presenting another controller:
self.present(vc, animated: true, completion: { /* code after presentation */ })
For example:
self.present(vc, animated: true) {
print("New view presented")
}
The same goes for background threads: after some API call is completed, we can use the closure to process the data we obtained — and remember to always update the UI on the main thread.
Example of downloading an image:
import UIKit
class ViewController: UIViewController {
let imageView = UIImageView()
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
print("Begin of code")
let url = URL(string: "https://image.shutterstock.com/image-vector/example-red-square-grunge-stamp-600w-327662909.jpg")!
downloadImage(from: url)
print("End of code. The image will continue downloading in the background and it will be loaded when it ends.")
}
func getData(from url: URL, completion: @escaping (Data?, URLResponse?, Error?) -> ()) {
URLSession.shared.dataTask(with: url, completionHandler: completion).resume()
}
func downloadImage(from url: URL) {
print("Download Started")
getData(from: url) { data, response, error in
guard let data = data, error == nil else { return }
print(response?.suggestedFilename ?? url.lastPathComponent)
print("Download Finished")
// Always update the UI from the main thread!
DispatchQueue.main.async { [weak self] in
self?.imageView.image = UIImage(data: data)
}
}
}
}
Memory Management
At first, there was no ARC back in the Objective-C days. We used to retain and release objects by ourselves. Nowadays, there is ARC (Automatic Reference Counting) that does this automatically at compile time.
ARC manages object life cycles by keeping track of all valid references to an object with an internal retain count. Once all references to an object go out of scope or are cleared, and the retain count reaches zero, the object and its underlying memory are automatically freed.
There are two main problems with memory management:
- Freeing or overwriting data that is still in use → causes memory corruption or crashes.
- Not freeing data that is no longer in use → causes memory leaks.
For example, if you load a 200MB video on one screen, then move to another without freeing the allocated memory, and come back again (allocating another 200MB), you’ll eventually crash due to memory pressure.
Golden rules
- We own the objects we create, and we have to subsequently release them when they are no longer needed.
- Use
retainto gain ownership of an object you did not create. You have to release these objects too when they are not needed. - Don’t release the objects that you don’t own.
With ARC, you don’t manually call release and retain. Objects are released when they go out of scope. But beware of strong reference cycles (retain cycles). For delegates, always use weak properties.
Tools
- Xcode Memory Gauge (Debug Navigator → Memory).
- Instruments (Profile in Instruments → Leaks / Allocations templates).
Bonus — Singleton objects
A singleton object is a shared resource within a single unique class instance.
Example with a singleton:
import Foundation
class Words: NSObject {
var firstWord = "Church"
var secondWord = "Palace"
var thirdWord = "Castle"
static let shared = Words()
}
Usage in a ViewController:
import UIKit
class ViewController: UIViewController {
var firstLabel = UILabel()
var secondLabel = UILabel()
var thirdLabel = UILabel()
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
firstLabel.text = Words.shared.firstWord
secondLabel.text = Words.shared.secondWord
thirdLabel.text = Words.shared.thirdWord
}
}
Example without a singleton:
import Foundation
struct Words {
var firstWord = "Church"
var secondWord = "Palace"
var thirdWord = "Castle"
}
import UIKit
class ViewController: UIViewController {
var firstLabel = UILabel()
var secondLabel = UILabel()
var thirdLabel = UILabel()
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
firstLabel.text = Words().firstWord
secondLabel.text = Words().secondWord
thirdLabel.text = Words().thirdWord
}
}
Much cleaner and simpler — you’re using memory only when you need it, and ARC frees it after.
⚠️ Singletons were popular in Objective-C era, but they:
- Make debugging/unit testing harder,
- Live for the entire app lifetime,
- Should be used sparingly.
Conclusion
Threads let you run multiple tasks simultaneously (with GCD/DispatchQueues).
ARC manages your memory automatically but you must avoid retain cycles.
Singletons exist but aren’t always the right choice.
As always, happy coding ✨
Also published on Medium.