17 · The main thread doing heavy lifting
An articles list that freezes on open and stutters on scroll — find the work that shouldn't be on the main thread, and move it.
Shape: find the bug · Reported: Uber — a debugging round on "standard iOS problems (sync/async, main thread violations)"; Apple — a "Multi threading / Performance round" · Verified: snippet and fix typecheck in Swift 6 mode with zero warnings, Swift 6.4; the timings were run on an M1 Pro Mac
"Users say the articles screen freezes for a second or two when it opens, and then the list stutters when they scroll. Nothing crashes in our tests. What's wrong, how would you prove it, and how would you fix it?"
struct Article: Decodable {
let id: String
let title: String
let publishedAt: Date
let thumbnailURL: URL
}
final class ArticlesViewController: UITableViewController {
private var articles: [Article] = []
private var favourites: Set<String> = []
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "ArticleCell")
let url = URL(string: "https://api.example.com/articles")!
let data = try! Data(contentsOf: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let loaded = try! decoder.decode([Article].self, from: data)
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
if let saved = try? Data(contentsOf: documents.appendingPathComponent("favourites.json")) {
favourites = (try? decoder.decode(Set<String>.self, from: saved)) ?? []
}
for article in loaded {
articles.append(article)
tableView.reloadData()
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
articles.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ArticleCell", for: indexPath)
let article = articles[indexPath.row]
let formatter = DateFormatter()
formatter.dateFormat = "dd MMM yyyy"
var content = cell.defaultContentConfiguration()
content.text = article.title
content.secondaryText = formatter.string(from: article.publishedAt)
if let imageData = try? Data(contentsOf: article.thumbnailURL) {
content.image = UIImage(data: imageData)
}
cell.contentConfiguration = content
cell.accessoryType = favourites.contains(article.id) ? .checkmark : .none
return cell
}
}This compiles in Swift 6 mode with no warnings at all. Swift 6 checks that data isn't shared unsafely between threads; it doesn't check how long the main thread is kept busy.
A hint, if you're stuck
- Which of these lines wait for something outside the app — the network, the disk?
viewDidLoadandcellForRowAtboth run on the main thread. What else does the main thread do?- How many times does
cellForRowAtrun during one fast scroll? UIImage(data:)returns quickly. When does the real work happen?
How I'd debug it
I don't guess, I profile. Run on a real device, ideally an older one, with Instruments' Time Profiler and Hangs. Open the screen: a hang marker appears, and the main thread's heaviest stack is Data(contentsOf:) under viewDidLoad. Scroll: the hitches line up with Data(contentsOf:) and image decoding under cellForRowAt. The fix is right when those stacks leave the main thread and the markers go away.
The key — what I expect a senior to find
The two reported bugs come first, right after the crash.
try!on the network and the decode. Offline, behind a captive Wi-Fi page, or with one bad field in the JSON, the app crashes on open. Usetryand show an error state.- A network request that blocks the main thread (the freeze).
Data(contentsOf:)with anhttpsURL is a synchronous download: the main thread stops and waits for the whole response. Nothing redraws and no touch is handled until it returns. If this is the first screen at launch, the system can kill the app for taking too long to start. UseURLSession'sasyncAPI. - A network request per cell, on main (the stutter). The same call in
cellForRowAtdownloads every thumbnail while the user scrolls, one row at a time. This is the worst line in the file. Download in aTaskand set the image when it arrives. - No loading or error state. The user stares at an empty list while it loads, and a failure would show nothing at all. Show a spinner, then rows or a message.
- A fixed
dateFormatignores the user's region."dd MMM yyyy"forces day-month-year on a US user. Use a style (.abbreviated) and let the locale decide the order. - Nothing can be tested. The URL,
URLSession,FileManager.defaultand the decoding all live insideviewDidLoad, so a test can't feed it data. Inject the loading as one closure. - Full-size image decoding on main.
UIImage(data:)doesn't decode yet; the pixels are unpacked the first time the image is drawn — on the main thread, at full size, mid-scroll. A 3,000-pixel photo shown at 60 points is mostly wasted work. Shrink and decode off main withbyPreparingThumbnail(ofSize:). - No image cache. Scroll down and back up and every thumbnail downloads again. Keep them in an
NSCachekeyed by URL. - JSON decoded on main. On my M1 Pro Mac, decoding 5,000 small articles took 22–29 ms — more than a whole frame at 60 Hz. Treat a phone as the same order of magnitude; an older phone is slower.
- A synchronous file read on main. Usually tiny, but the main thread has no business waiting on the disk, and the file only grows.
reloadData()once per article. Pointless work that grows with the list. UIKit defers building cells to the next layout pass, but each call still invalidates the whole table. Fill the array, reload once.- A new
DateFormatterfor every cell. Creating one per row cost about 100 µs; reusing one, or aDate.FormatStyle, cost 1–2 µs. Rarely a dropped frame on its own, but the cheapest fix here: make itstatic. - Favourites decoded with the articles' decoder. It works, but a decoder set up for ISO-8601 dates has nothing to do with a set of IDs. Use a plain
JSONDecoder(). - Magic strings and a force unwrap.
"ArticleCell"is typed twice; one typo crashes at dequeue.URL(string:)!on a literal is safe but reads as careless — make it astatic let.
How I measured 9 and 12: a Foundation-only program, swiftc -O, run twice on an M1 Pro Mac.
The idea behind it
The screen is redrawn many times a second: 60 times on most iPhones, up to 120 on ProMotion displays. Each new picture is a frame. To keep up, every frame must be ready in 1/60 s — about 16.7 milliseconds — or 1/120 s, about 8.3 ms. That's the frame budget.
The main thread is the one line of work that does all of the UI: it handles your touches, runs cellForRowAt, lays out views and hands the finished frame over. It can only do one thing at a time. If you give it 40 ms of downloading or decoding, it misses two or more frames, and the scroll jumps. A late frame is called a hitch. If the main thread is stuck long enough that taps stop working — Apple's tools start reporting at about 250 ms — that's a hang.
Picture a waiter who is the only one allowed to talk to customers. If he goes into the kitchen to cook a steak himself, nobody gets served. The fix is to keep him at the tables: send the cooking (network, disk, decoding, image shrinking) to the kitchen — background threads — and have him carry only the finished plate to the table, on the main thread.
Main Thread Checker catches the opposite mistake — UI touched off the main thread — and is silent about this code. Slow work on the main thread is legal; it's just slow.
The fix
struct Article: Decodable, Sendable { // crosses threads now
let id: String
let title: String
let publishedAt: Date
let thumbnailURL: URL
}
final class ArticlesViewController: UITableViewController {
private var articles: [Article] = []
private var favourites: Set<String> = []
private static let dateStyle = Date.FormatStyle(date: .abbreviated, time: .omitted) // key 5, 12
var load: @Sendable () async throws -> ([Article], Set<String>) = loadArticles // key 6: the seam
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "ArticleCell")
let load = self.load
Task { [weak self] in
do {
let (loaded, saved) = try await load() // key 1, 2, 9, 10: off main, no try!
guard let self else { return }
articles = loaded
favourites = saved
tableView.reloadData() // key 11: once
} catch {
let label = UILabel() // key 1, 4: an error state, not a crash
label.text = "Couldn't load articles."
self?.tableView.backgroundView = label
}
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
articles.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ArticleCell", for: indexPath)
let article = articles[indexPath.row]
var content = cell.defaultContentConfiguration()
content.text = article.title
content.secondaryText = article.publishedAt.formatted(Self.dateStyle)
content.image = UIImage(systemName: "photo") // placeholder
content.imageProperties.maximumSize = CGSize(width: 60, height: 60)
cell.contentConfiguration = content
cell.accessoryType = favourites.contains(article.id) ? .checkmark : .none
let side = 60 * tableView.traitCollection.displayScale
Task { [weak tableView] in // key 3, 7
guard let image = try? await Self.thumbnail(article.thumbnailURL, side: side),
let cell = tableView?.cellForRow(at: indexPath), // nil if scrolled away
var content = cell.contentConfiguration as? UIListContentConfiguration else { return }
content.image = image
cell.contentConfiguration = content
}
return cell
}
// @concurrent: always runs on a background thread, never on the caller's actor.
@concurrent private static func loadArticles() async throws -> ([Article], Set<String>) {
let (data, _) = try await URLSession.shared.data(from: URL(string: "https://api.example.com/articles")!)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let loaded = try decoder.decode([Article].self, from: data)
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let saved = try? Data(contentsOf: documents.appendingPathComponent("favourites.json"))
let favourites = saved.flatMap { try? JSONDecoder().decode(Set<String>.self, from: $0) } ?? [] // key 13
return (loaded, favourites)
}
@concurrent private static func thumbnail(_ url: URL, side: CGFloat) async throws -> UIImage? {
let (data, _) = try await URLSession.shared.data(from: url)
return await UIImage(data: data)?.byPreparingThumbnail(ofSize: CGSize(width: side, height: side))
}
}Said out loud, not coded: a spinner while loading (key 4) · an NSCache for thumbnails (key 8) · a cell subclass that cancels its download in prepareForReuse · a Retry button · moving loading out of the controller into a view model · a static let for the reuse ID and URL.
Why each piece:
@concurrent— guarantees the download, the decoding, the file read and the image shrinking run off the main actor. A plainnonisolated asyncfunction may run on the caller's actor under the newernonisolated(nonsending)default (SE-0461);@concurrentis clear under both.- Code after
awaitin theTaskruns on main — the controller is@MainActor, so settingarticlesand callingreloadData()is on the right thread by construction. cellForRow(at:)after the download, not the cell captured earlier — a captured cell may have been reused for another row by then;cellForRow(at:)returnsnilfor a row that's gone.maximumSizeon the image — a thumbnail made fromDatahas scale 1, so a 180-pixel image would otherwise lay out as 180 points. This pins it to the 60-point slot.
Now write the tests
"Good. Now write me a few tests. I know you can't unit-test 'it's smooth' — so what can you test?"
What I'd test, and why
- The screen loads once and reloads once. The old code reloaded the table once per article. A spy table — a subclass that counts
reloadData()calls — pins "once". - A failed load shows an error and no rows — the old
try!crashed on open. - A favourite is ticked — the regression check: moving the loading must not lose the favourites.
What a unit test can't prove is the point of the chapter: that the work happens off the main thread and the scroll stays smooth. That's measured with Instruments on a device, before and after.
The seam. The load closure is a property with a real default, so a test swaps in a fixed answer — no network, no disk. The test waits for what a user would see (the table reloaded, the error shown) with waitUntil(maxYields:_:), which gives the main actor a turn up to a fixed number of times. No clock, so it can't be flaky. One detail: the spy table goes in after loadViewIfNeeded(). Assigning tableView first creates the view without calling viewDidLoad, and nothing would load.
import Testing
import UIKit
// Counts how many times the screen asked for articles.
actor LoadCounter {
private(set) var count = 0
func increment() { count += 1 }
}
final class TableSpy: UITableView {
private(set) var reloadCount = 0
override func reloadData() {
reloadCount += 1
super.reloadData()
}
}
/// Gives the main actor a turn, up to a fixed number of times, until the condition holds.
/// A count of turns, not a clock, so a slow machine can't make it flaky.
@MainActor
func waitUntil(maxYields: Int = 1_000, _ condition: () async -> Bool) async {
for _ in 0..<maxYields {
if await condition() { return }
await Task.yield()
}
}
func makeArticle(_ id: String) -> Article {
// /dev/null reads as empty data, so the thumbnail task ends at once, with no network.
Article(id: id, title: "Article \(id)", publishedAt: Date(timeIntervalSince1970: 0),
thumbnailURL: URL(fileURLWithPath: "/dev/null"))
}
struct LoadFailed: Error {}
/// Loads the screen, then swaps in a spy table. The order matters: assigning `tableView`
/// first would create the view without calling viewDidLoad, and nothing would load.
@MainActor
func makeScreen(load: @escaping @Sendable () async throws -> ([Article], Set<String>))
-> (ArticlesViewController, TableSpy) {
let screen = ArticlesViewController(style: .plain)
screen.load = load
screen.loadViewIfNeeded()
let table = TableSpy()
table.register(UITableViewCell.self, forCellReuseIdentifier: "ArticleCell")
screen.tableView = table
return (screen, table)
}
@Suite(.timeLimit(.minutes(1)))
@MainActor
struct ArticlesViewControllerTests {
@Test func loadsOnceAndReloadsOnce() async {
// Given a loader with three articles that counts its calls
let counter = LoadCounter()
let (screen, table) = makeScreen {
await counter.increment()
return (["1", "2", "3"].map(makeArticle), [])
}
// When the load lands
await waitUntil { table.reloadCount > 0 }
// Then it asked once, reloaded once, and shows three rows
#expect(await counter.count == 1)
#expect(table.reloadCount == 1)
#expect(screen.tableView(table, numberOfRowsInSection: 0) == 3)
}
@Test func failedLoadShowsAnErrorAndNoRows() async {
let (screen, table) = makeScreen { throw LoadFailed() }
await waitUntil { table.backgroundView != nil }
#expect((table.backgroundView as? UILabel)?.text == "Couldn't load articles.")
#expect(screen.tableView(table, numberOfRowsInSection: 0) == 0)
#expect(table.reloadCount == 0)
}
@Test func favouriteIsTicked() async {
let (screen, table) = makeScreen { (["1", "2"].map(makeArticle), ["2"]) }
await waitUntil { table.reloadCount > 0 }
let first = screen.tableView(table, cellForRowAt: IndexPath(row: 0, section: 0))
let second = screen.tableView(table, cellForRowAt: IndexPath(row: 1, section: 0))
#expect(first.accessoryType == .none)
#expect(second.accessoryType == .checkmark)
}
}Ran on the iOS Simulator (Swift 6 mode): 3 tests, all passed.
What I'd ask next
- "Why
@concurrent? Isn't anasyncfunction already off the main thread?" — It depends. Under the classic Swift 6 rules, anonisolated asyncfunction runs on the background pool. With the newer setting that Xcode 26 turns on for new projects (nonisolated(nonsending) by default, SE-0461), it runs on the caller's actor — so called from the main actor, the decode would be back on main.@concurrentstates the intent and works under both. - "How do you prove the fix worked?" — Profile before and after on a real device, ideally an older one, with the Time Profiler and Hangs instruments: the main thread's heavy stacks should disappear and the hang markers on open should go. Xcode Organizer's hang rate shows the same for users in the field.
- "Where does the image cache go?" — Next to the thumbnail download: an
NSCachekeyed by URL and size, checked before the request. Then scrolling back up is free. - "A fast scroll starts a download for every row it passes. Then what?" — Move the
Taskinto a cell subclass, keep it in a property, and cancel it inprepareForReuse(chapter 01). - "Is
DateFormattersafe to share across threads?" — Yes, on iOS 7 and later it is thread-safe for formatting. The real constraint is not mutating its settings while others use it, which astatic letyou configure once avoids.