Avoid Queue-Jumping
As humans, we are great at spotting patterns, but see that pattern often enough, and it becomes familiar and eventually, effectively invisible - that clock ticking, that smudge mark on the wall or that weird smell. It's the same with code - that odd syntax or awkward call-site eventually stops standing out. That's why our projects are often cluttered with these oddities. We treat them as harmless because they become familiar, but they're not, especially for new developers trying to understand the project. DispatchQueue.main code blocks are one such pattern that we stop seeing. But having DispatchQueue.main code blocks scattered throughout our projects makes our code harder to understand and test.

This post will explore how we can cut that clutter by being more thoughtful about where we place those DispatchQueue.main calls.
Limiting Queue-Jumping 🦘
It's not unusual to see queue-jumping throughout a project. Take this class that undertakes asynchronous work on a background queue and returns (via a closure) the result of that work:
class Worker {
// 1
func performWork(completion: @escaping (Result<String, Error>) -> Void) {
// 2
DispatchQueue.global().async {
// TODO: Perform work
// 3
completion(.success("Done"))
}
}
}
performWork(completion:)takes a singlecompletionclosure parameter. It usesResultenum type to communicate success or failure back to the caller.- The work is dispatched onto a global background queue.
completionis triggered directly from inside the background queue.
Now let's see performWork(_:) being used in an example view-model:
class ViewModel {
private let worker = Worker()
func process() {
worker.performWork { result in
// 1
DispatchQueue.main.async {
switch result {
case .success(let value):
// TODO: Do something
case .failure(let error):
// TODO: Do something else
}
}
}
}
}
- Because
performWork(_:)doesn't offer any control over the callback queue, the caller has to manually move the response handling onto themainqueue - this is queue-jumping.
Every call-site that cares about which queue it's called back on will need to include this same boilerplate.
We can do better than that 😎.
Capturing the Queue
Let's update Worker to allow the caller to specify which queue to be called back on:
class Worker {
// 1
func performWork(callbackQueue: DispatchQueue,
completion: @escaping (Result<String, Error>) -> Void) {
DispatchQueue.global().async {
// TODO: Perform work
// 2
callbackQueue.async {
completion(.success("Done"))
}
}
}
}
callbackQueuecontrols which queue thecompletionfires on - we're shifting control of execution context from the callee to the caller.- Rather than just directly triggering
completion, that triggering is now scheduled asynchronously oncallbackQueue- ensuring the caller receives the callback on the queue they specified.
If this approach feels familiar, you might be thinking of the
delegateQueueparameter on theURLSessionwhich I've shamelessly been inspired by.
With Worker absorbing the queue-jumping code, we can simplify each call-site:
class ViewModel {
private let worker = Worker()
func process() {
worker.performWork(callbackQueue: .main) { result in
switch result {
case .success(let value):
// TODO: Do something
case .failure(let error):
// TODO: Do something else
}
}
}
}
If we wanted to instead have result processed on another queue, it would look like:
class ViewModel {
private let worker = Worker()
private let processingQueue = DispatchQueue(label: "com.williamboles.processing")
func process() {
worker.performWork(callbackQueue: processingQueue) { result in
switch result {
case .success(let value):
// TODO: Do something
case .failure(let error):
// TODO: Do something else
}
}
}
}
With callbackQueue handling the dispatch, we've pushed the queue-jumping out of the call-site, and the responsibilities of process() are easier to see.
The callbackQueue parameter has already simplified our call-sites, but every caller still has to think about which queue they want. In practice, the most common use-case will be .main. So rather than making every call-site state the queue, we can default callbackQueue to .main:
class Worker {
func performWork(callbackQueue: DispatchQueue = .main,
completion: @escaping (Result<String, Error>) -> Void) {
// TODO: Omitted unchanged functionality
}
}
With this change our call-sites can now look like:
class ViewModel {
private let worker = Worker()
func process() {
worker.performWork { result in
switch result {
case .success(let value):
// TODO: Do something
case .failure(let error):
// TODO: Do something else
}
}
}
}
Now, callers that need the main queue don't need to think about queues at all and those that don't want the main queue can easily override the default. We've reduced friction without removing flexibility.
When the queue is hardcoded inside the method, there's no reliable way for a test to verify which queue the callback fired on. By making the callback queue injectable, we bring that behaviour under test:
final class WorkerTests: XCTestCase {
// 1
var sut: Worker!
override func setUp() {
super.setUp()
sut = Worker()
}
override func tearDown() {
sut = nil
super.tearDown()
}
// 2
func test_givenPerformWorkIsCalled_whenUsingDefaultCallbackQueue_thenCallsBackOnMainQueue() {
let expectation = expectation(description: "completion called")
sut.performWork { result in
XCTAssertTrue(Thread.isMainThread)
XCTAssertEqual(try? result.get(), "Done")
expectation.fulfill()
}
waitForExpectations(timeout: 5)
}
// 3
func test_givenPerformWorkIsCalled_whenSpecifyingCallbackQueue_thenCallsBackOnThatQueue() {
let callbackQueue = DispatchQueue(label: "com.williamboles.test-callback")
let callbackQueueKey = DispatchSpecificKey<Bool>()
callbackQueue.setSpecific(key: callbackQueueKey, value: true)
let expectation = expectation(description: "completion called")
sut.performWork(callbackQueue: callbackQueue) { result in
XCTAssertTrue(DispatchQueue.getSpecific(key: callbackQueueKey) == true)
XCTAssertEqual(try? result.get(), "Done")
expectation.fulfill()
}
waitForExpectations(timeout: 5)
}
}
sut(system under test) is created fresh insetUpand torn down after each test, ensuring a clean state for every test run.- This test verifies the default behaviour - that when no
callbackQueueis specified, the completion fires on the main queue.Thread.isMainThreadis a straightforward check here since the main queue always maps to the main thread. - This test verifies the override path - that when a custom
callbackQueueis specified, the completion fires on that queue. Since there's noDispatchQueue.currentAPI to know which queue the code is currently being run on, we tag the queue with aDispatchSpecificKeyand check for it inside the callback to confirm we're on the right queue - a boolean value oftrueis chosen to make the assert straightforward but the real work is done with the key matching.
Wrapping Up
By adding a callbackQueue parameter to our asynchronous methods, we shift the queue-jumping responsibility from every call-site to a single, predictable location inside the callee. Most callers get the sensible default of .main for free, and the few that need something different can override it explicitly. The scattered DispatchQueue.main code blocks that had faded into the background are replaced by a single, intentional decision at the source.
To see the complete working example, visit the repository and clone the project.