Concurrency in Swift and Objective-C: Building Performant UI in Modern macOS Apps

Image Source: depositphotos.com

Smooth scrolling, responsive action, and seamless interactions are what users have come to expect from macOS apps — but getting it done requires careful handling of concurrent operations, especially when it comes to network requests, file I/O, image processing, and computation-bound work.

The introduction of Swift Concurrency in iOS 15 and macOS 12 changed how we're approaching asynchronous programming on Apple platforms. The reality, however, is that the majority of production macOS applications still retain vast Objective-C codebases, often representing years of development and battle-tested functionality. The issue isn't a choice between the old and the new — it's how to take the best of both approaches and create efficient applications.

In this article I’ll cover pragmatic strategies for capitalizing on both older concurrency tools and latest Swift features to create macOS interfaces, with particular emphasis on the unique challenges of hybrid codebases.

Understanding the Problem: UI and Concurrency

One of the most fundamental rules in UI programming remains the same: never block the main thread. When you perform long-running work on the main queue, the entire interface is frozen. Users see frozen animations, delayed button feedback, and the dreaded spinning beach ball.

Consider a document export operation that takes several seconds to process the data. If this operation is performed on the main thread, the entire application is frozen. The window can't be moved, menus are not responsive, and other UI components are stuck. This gives a terrible user experience that is broken and unprofessional.

UI jank occurs when the main thread is unable to service the run loop frequently enough to achieve smooth 60fps rendering. The solution is to move computationally expensive work off of the main thread but still maintain UI updates on the main queue — a classic concurrency issue that requires the right tools and patterns.

The Legacy Tools: GCD and NSOperation in Objective-C

Objective-C developers have relied primarily on Grand Central Dispatch (GCD) and NSOperationQueue for concurrency programming prior to Swift Concurrency. They remain robust and are still widely used in production apps today.

The traditional GCD approach is to dispatch work out to background queues and to dispatch UI updates back to the main queue. This is a good pattern but is prone to manual queue management and leads to nested callback patterns that are difficult to trace in a complicated scenario.

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    NSData *data = [self generateLargeReport];
    dispatch_async(dispatch_get_main_queue(), ^{
        [self updateUIWithData:data];
    });
});

NSOperationQueue also provides operation dependencies and cancellation, but with similar trade-offs in complexity. The callback nature of these APIs leads to highly nested code hierarchies that are hard to read and maintain.

Swift Concurrency: async/await and Structured Concurrency

Swift Concurrency introduces async/await syntax that makes asynchronous code read like synchronous code, eliminating callback pyramids and reducing cognitive overhead. Combined with structured concurrency, it provides powerful tools for managing concurrent operations safely.

The key innovations include the Task type for managing concurrent work, @MainActor for ensuring UI updates happen on the main thread, and actors for protecting shared mutable state. The await keyword clearly indicates where functions might suspend, allowing other work to proceed while maintaining readable, linear code flow.

This approach eliminates the nested callback structure while providing compile-time guarantees about where UI updates occur. The @MainActor annotation ensures thread safety without requiring explicit dispatch calls.

Mixing Swift and Objective-C in a Real App

Most macOS apps in production aren’t written entirely in Swift — they’re a mix of Swift and Objective-C, often with a web of legacy code and bridged APIs. That makes managing concurrency tricky. You need to pay close attention to thread safety and how async code flows across the language boundary.

If you’re calling Swift async functions from Objective-C, you’ll usually need to wrap them in a method that uses Task {} to run the async code, then call a traditional completion handler with the result.

Going the other way — calling Objective-C completion-based APIs from Swift — withCheckedContinuation comes in handy. It lets you turn a callback into an async call while still playing nicely with Swift’s structured concurrency.

The most important thing is to draw clear lines between synchronous Objective-C code and asynchronous Swift code, using well-defined bridging points to make sure they talk to each other safely and predictably.

Building a Responsive UI: Practical Patterns

Lazy Loading and Progressive Disclosure

Large datasets require careful loading strategies to maintain smooth scrolling performance. Instead of loading everything upfront, implement lazy loading that fetches data only when needed. This is particularly important for table views and collection views displaying hundreds or thousands of items.

The pattern involves canceling previous loading operations when cells are reused, starting new async operations for visible content, and gracefully handling cancellation when cells scroll out of view. This ensures that only visible content consumes resources while maintaining smooth scrolling performance.

Debounced User Input

Search-as-you-type functionality requires debouncing to avoid overwhelming servers with requests. The pattern involves canceling previous search tasks when new input arrives, waiting a short delay (typically 200-300ms), and then performing the actual search if the input hasn't changed.

This approach dramatically reduces server load while providing a responsive user experience. Users see results quickly without the application making excessive network requests for every keystroke.

Chunked Background Processing

Large operations benefit from being broken into smaller chunks with periodic UI updates. This pattern involves processing data in small batches, updating progress indicators between chunks, and yielding control to allow other tasks to run.

The key insight is that long-running operations should be interruptible and provide regular feedback to users. Breaking work into chunks allows for cancellation, progress reporting, and prevents the appearance of an unresponsive application.

Measuring and Debugging Performance

Effective performance optimization requires measurement. Xcode provides several tools specifically designed for concurrency debugging that every macOS developer should understand and use regularly.

Instruments remain the gold standard for performance analysis. The Time Profiler instrument shows exactly where CPU time is spent, while the System Trace instrument reveals thread scheduling and queue utilization patterns. These tools are essential for identifying bottlenecks and understanding how concurrent operations interact.

Main Thread Checker automatically detects UI API usage off the main thread — a common source of crashes and undefined behavior. This should be enabled in all development builds as it catches issues that might not manifest consistently during testing.

Swift Concurrency debugging in Xcode 14+ provides runtime checks for actor isolation violations and improper async context usage. These diagnostics help identify subtle concurrency bugs that would be difficult to catch through traditional testing.

Custom logging and timing can also provide valuable insights into application performance. Consider adding telemetry to track how long operations take and where bottlenecks occur in production usage.

Common Pitfalls and How to Avoid Them

Unstructured Task Creation

Creating tasks without proper lifecycle management leads to resource leaks and unpredictable behavior. Tasks that aren't cancelled when their owning objects are deallocated can continue running indefinitely, consuming resources and potentially causing crashes.

The solution is to always tie task lifetimes to object lifetimes, storing task references and canceling them during deinitialization. This ensures that concurrent operations don't outlive the objects that created them.

Memory Management in Hybrid Codebases

Mixing retain cycles from Objective-C completion blocks with Swift async patterns creates subtle memory leaks that are difficult to diagnose. The callback-based nature of Objective-C APIs can capture strong references to Swift objects, preventing deallocation.

Careful use of weak references and explicit capture lists helps avoid these issues. When bridging between languages, always consider the ownership implications of closures and completion handlers.

Thread Safety Across Language Boundaries

Objective-C objects accessed from Swift’s async contexts require explicit synchronization because they don’t benefit from Swift’s compile-time concurrency checks. This means the compiler can’t guarantee thread safety for Objective-C instances, so developers need to manage shared state carefully — especially when crossing the boundary between Swift and Objective-C.

Actors provide a clean solution for protecting shared state, but they require careful API design to work effectively with Objective-C code. Consider using actors for new Swift code while maintaining explicit locking for existing Objective-C objects.

Overuse of Global Queues

While global queues are convenient, they can lead to thread explosion and poor performance characteristics. Creating too many concurrent operations can overwhelm the system and actually decrease performance due to context switching overhead.

Consider using operation queues with explicit concurrency limits or custom actors to control the degree of parallelism in your application. The goal is finding the right balance between responsiveness and resource usage.