Introduction to RunLoop Programming on macOS
A RunLoop is one of the most fundamental yet frequently misunderstood concepts in macOS and iOS development. At its core, a RunLoop is a loop that keeps your application alive, waiting for events and dispatching them to the appropriate handlers. Without a RunLoop, a command-line program would simply execute its main function and exit. With a RunLoop, the program stays alive, listening for input sources, timers, and system events.
On macOS, RunLoops power everything from UI event handling in AppKit to asynchronous networking with URLSession. Understanding how they work is essential for building responsive, efficient, and robust applications.
What Exactly Is a RunLoop?
Conceptually, a RunLoop is a while-loop that performs the following steps repeatedly:
- Notifies registered observers that the loop is about to process timers and input sources.
- Processes any ready timers whose firing time has arrived.
- Processes input sources that have events waiting.
- Notifies observers that the loop is about to sleep.
- Sleeps until one of the following occurs: an input source fires, a timer fires, the RunLoop timeout expires, or the RunLoop is explicitly woken up.
- Notifies observers that the loop has woken up, then repeats.
This cycle allows your application to remain idle when there is nothing to do, conserving CPU resources, while still responding immediately when events arrive.
Core Concepts and Components
RunLoop Modes
A RunLoop mode is a collection of input sources and timers that are monitored together. Only sources associated with the current mode are processed during a given iteration of the loop. macOS defines several built-in modes:
default— The standard mode used for most application work.common— A pseudo-mode that groups together modes that should all receive the same sources.tracking— Used during UI tracking, such as when a user drags a slider or scrolls.eventReceive— Used for receiving system events.
The most important practical implication of modes is that timers scheduled in the default mode will not fire while the user is interacting with a modal UI element in tracking mode. This is why NSTimer often appears to pause during scrolling. The fix is to add the timer to the common modes.
Input Sources
Input sources deliver events asynchronously to your application. There are two categories:
- Port-based sources — Built on Mach ports, these are managed by the kernel and automatically signal the RunLoop when data arrives.
- Custom sources — Application-defined sources that you must signal manually from another thread.
Timers
Timers are a special kind of input source that fire at a scheduled time or interval. They are not real-time; they only fire when the RunLoop is running in a mode that includes the timer, and only after the scheduled firing time has passed.
Observers
Observers allow you to monitor the activity of a RunLoop. You can register an observer to be notified at specific stages of the loop, such as before timers are processed, before the loop sleeps, or after it wakes up. This is useful for performance monitoring and for performing idle-time work.
Accessing the RunLoop
Every NSThread object, including the main thread, has a RunLoop. You access it using the NSRunLoop class method currentRunLoop. The main thread's RunLoop is created automatically by the application framework; secondary thread RunLoops are created lazily when first accessed.
import Foundation
// Access the current thread's RunLoop
let runLoop = RunLoop.current
// Access the main thread's RunLoop from any thread
let mainRunLoop = RunLoop.main
For lower-level control, you can use the Core Foundation CFRunLoop API, which is toll-free bridged with NSRunLoop on macOS.
import Foundation
let cfRunLoop = CFRunLoopGetCurrent()
let mainCFRunLoop = CFRunLoopGetMain()
Running a RunLoop
On the main thread, the RunLoop is started for you by NSApplication. On secondary threads, if you want to use timers or port-based sources, you must start the RunLoop yourself.
Running Indefinitely
import Foundation
func startRunLoopOnBackgroundThread() {
Thread.detachNewThread {
// Schedule some work before running
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
print("Timer fired on background thread")
}
// Run the RunLoop indefinitely
RunLoop.current.run()
}
}
The run() method runs the loop in the default mode indefinitely. It only returns when all input sources and timers are removed. This is the simplest approach but offers the least control.
Running with a Time Limit
import Foundation
func runForLimitedTime() {
let runLoop = RunLoop.current
let future = Date(timeIntervalSinceNow: 5.0)
// Run for up to 5 seconds, or until an event arrives
runLoop.run(until: future)
print("RunLoop finished or timed out")
}
This approach is useful when you want to process events for a bounded period, such as waiting for an asynchronous operation to complete while still keeping the thread responsive.
Running with a Condition
import Foundation
var shouldKeepRunning = true
func runWithCondition() {
let runLoop = RunLoop.current
let port = Port()
runLoop.add(port, forMode: .default)
while shouldKeepRunning {
runLoop.run(until: Date(timeIntervalSinceNow: 0.1))
}
runLoop.remove(port, forMode: .default)
print("RunLoop exited cleanly")
}
This pattern gives you the most control. You add a port to prevent the RunLoop from exiting immediately (since an empty RunLoop returns right away), and you check a flag after each short run to decide whether to continue. This is the recommended pattern for secondary thread RunLoops in Apple's documentation.
Working with Timers
Timers are the most common reason developers interact with RunLoops directly. Here is how to schedule a timer correctly, including adding it to common modes so it fires during UI tracking.
import Foundation
let timer = Timer(timeInterval: 0.5, repeats: true) { _ in
print("Timer fired at \(Date())")
}
// Add to the common modes so it fires during scrolling and dragging
RunLoop.current.add(timer, forMode: .common)
Note that Timer.scheduledTimer adds the timer to the current RunLoop in the default mode only. If you need common modes, you must create the timer with Timer(timeInterval:repeats:block:) and add it manually.
Using Ports for Inter-Thread Communication
Port-based input sources are the recommended way to communicate between threads. The following example shows a worker thread that receives messages from the main thread through a Mach port.
import Foundation
class WorkerThread {
private var thread: Thread?
private var port: Port?
func start() {
thread = Thread { [weak self] in
guard let self = self else { return }
let receivePort = Port()
self.port = receivePort
// Add the port to this thread's RunLoop
RunLoop.current.add(receivePort, forMode: .default)
// Keep the RunLoop alive
while !Thread.current.isCancelled {
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.5))
}
}
thread?.start()
}
func sendMessage() {
guard let port = port else { return }
let data = "Hello from main".data(using: .utf8)!
// In a real app you would send data through the port
print("Sending data to worker thread via port: \(port)")
}
func stop() {
thread?.cancel()
}
}
In practice, many developers use Grand Central Dispatch (GCD) instead of manual port-based communication because it is simpler. However, understanding ports is valuable when you need fine-grained control over thread lifecycle and message ordering.
RunLoop Observers
Observers let you hook into the RunLoop cycle. This is useful for performance instrumentation, idle-time garbage collection, or triggering layout passes. The Core Foundation API is required because NSRunLoop does not expose observer functionality directly.
import Foundation
func addRunLoopObserver() {
var context = CFRunLoopObserverContext(
version: 0,
info: nil,
retain: nil,
release: nil,
copyDescription: nil
)
let observer = CFRunLoopObserverCreate(
kCFAllocatorDefault,
CFRunLoopActivity.allActivities.rawValue,
true,
0,
{ _, activity, _ in
switch activity {
case .entry:
print("RunLoop entered")
case .beforeTimers:
print("About to process timers")
case .beforeSources:
print("About to process sources")
case .beforeWaiting:
print("About to sleep")
case .afterWaiting:
print("Just woke up")
case .exit:
print("RunLoop exited")
default:
break
}
},
&context
)
if let observer = observer {
CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, .defaultMode)
}
}
A common use case for the beforeWaiting activity is to perform low-priority work when the application is idle. This technique is used internally by Core Animation to commit layout updates at the optimal time.
Best Practices
Prefer GCD for Concurrency
In modern macOS development, GCD and operation queues are usually a better choice than manually managing threads and RunLoops. Use RunLoops directly only when you need features that GCD does not provide, such as timers that integrate with the current thread's event loop or port-based message passing.
Avoid Blocking the Main RunLoop
The main RunLoop drives the user interface. Any long-running task on the main thread will freeze the UI. Move expensive work to background queues and dispatch results back to the main thread only for UI updates.
import Foundation
func performExpensiveWork() {
DispatchQueue.global(qos: .userInitiated).async {
let result = heavyComputation()
DispatchQueue.main.async {
updateUI(with: result)
}
}
}
Use Common Modes for Critical Timers
If a timer must fire during UI interaction, always add it to common modes. Otherwise, it will appear to stall during scrolling, dragging, or other tracking-mode activities.
Do Not Call run() on Secondary Threads Without a Source
The run() method exits immediately if there are no input sources or timers. If you call it in a loop without adding a source, you will spin the CPU. Always add a port or timer before running, or use the condition-based pattern shown earlier.
Be Careful with Retain Cycles in Timers
Timers retain their target. If the target retains the timer, you have a retain cycle. Use the block-based Timer API and capture self weakly to avoid this problem.
import Foundation
class Heartbeat {
private var timer: Timer?
func start() {
timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
self?.beat()
}
RunLoop.current.add(timer!, forMode: .common)
}
func stop() {
timer?.invalidate()
timer = nil
}
private func beat() {
print("Heartbeat at \(Date())")
}
}
Use RunLoop Observers for Performance Debugging
If your application feels janky, a RunLoop observer logging the time spent in each phase can help you identify which source or timer is consuming too much time. Tools like Instruments also leverage RunLoop activity to correlate with frame drops.
Putting It All Together: A Practical Example
The following example demonstrates a complete secondary-thread worker that uses a RunLoop, a timer, and a port for communication, with clean shutdown support.
import Foundation
final class BackgroundWorker {
private var thread: Thread?
private var inputPort: Port?
private var timer: Timer?
private var keepRunning = false
func start() {
thread = Thread { [weak self] in
guard let self = self else { return }
self.keepRunning = true
// Create and add a port to keep the RunLoop alive
let port = Port()
self.inputPort = port
RunLoop.current.add(port, forMode: .default)
// Schedule a periodic timer
let t = Timer(timeInterval: 2.0, repeats: true) { _ in
print("Worker tick at \(Date())")
}
self.timer = t
RunLoop.current.add(t, forMode: .default)
// Run until told to stop
while self.keepRunning && !Thread.current.isCancelled {
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.5))
}
// Cleanup
RunLoop.current.remove(port, forMode: .default)
t.invalidate()
print("Worker thread exiting")
}
thread?.name = "com.example.backgroundWorker"
thread?.start()
}
func stop() {
keepRunning = false
thread?.cancel()
}
}
// Usage
let worker = BackgroundWorker()
worker.start()
// Let it run for a while
Thread.sleep(forTimeInterval: 7)
worker.stop()
Conclusion
RunLoop programming on macOS is a foundational skill that explains how event-driven applications stay alive and responsive. While modern APIs like GCD and async/await handle much of the concurrency work developers need, the RunLoop remains the beating heart of every macOS application, coordinating timers, input sources, observers, and UI events. By understanding modes, sources, and the run cycle, you can diagnose subtle bugs like timers that pause during scrolling, build efficient background workers, and integrate cleanly with AppKit and Foundation. Use RunLoops deliberately, prefer higher-level abstractions when they suffice, and always respect the main thread's role in keeping your application responsive.