RunLoop — an event processing cycle in iOS, implemented by CFRunLoop (Core Foundation) and NSRunLoop (Foundation) objects. This mechanism waits for events (touches, timers, input sources, notifications) and dispatches them to the appropriate handlers on the thread. Each thread in iOS has at most one RunLoop, but it is automatically created only for the Main Thread. According to Apple CFRunLoop Documentation, RunLoop is critical for timers, animations, and monitoring sources on background threads.
Key Takeaways
RunLoop is a Core Foundation infrastructure object that organizes event processing on a thread. At its core, it is an infinite loop (while true) that waits for events (sources) to arrive and passes them to handlers. When there are no events, RunLoop puts the thread to sleep, saving battery power. When an event arrives, the thread wakes up, processes it, and goes back to sleep. RunLoop exists only in iOS/macOS (XNU + Core Foundation) — in Android, its role is performed by Looper.
Each thread has at most one RunLoop, which is created lazily on first access. For the Main Thread, RunLoop is created automatically when the app launches. For background threads, RunLoop is not created until CFRunLoopGetCurrent() or RunLoop.current is called. The main application RunLoop is responsible for processing touch events, screen rendering, executing DispatchQueue.main blocks, and serving Core Animation layers.
RunLoop is not a thread — it is a mechanism inside a thread. A thread can exist without a RunLoop (if it performs a synchronous task and finishes), but a RunLoop cannot exist without a thread. When a thread with an active RunLoop has no events, it does not block the CPU but enters a waiting state — this is a key difference from a busy-wait loop that consumes 100% CPU.
RunLoop processes two types of event sources: Input Sources and Timer Sources. Input Sources deliver asynchronous events: touches, mouse movements, socket data, messages from other threads (performSelector:onThread:). Timer Sources deliver scheduled synchronous events: NSTimer, CADisplayLink. There are also Observers — entry points for monitoring RunLoop state.
The RunLoop cycle consists of sequential phases: entering a mode (kCFRunLoopEntry), timer processing (kCFRunLoopBeforeTimers), input source processing (kCFRunLoopBeforeSources), source handling (kCFRunLoopAfterWaiting), waiting (sleep), and exiting the mode (kCFRunLoopExit). If no events are processed during the current iteration, RunLoop puts the thread to sleep indefinitely until woken by a new event.
import Foundation
// Demonstration of RunLoop phases through Observer
func observeRunLoopActivities() {
let observer = CFRunLoopObserverCreateWithHandler(
nil,
CFOptionFlags([[.entry, .beforeTimers, .beforeSources,
.afterWaiting, .exit]]),
true, // repeats
0 // priority
) { observer, activity in
switch activity {
case .entry:
print("Entry — RunLoop activated")
case .beforeTimers:
print("BeforeTimers — timer processing")
case .beforeSources:
print("BeforeSources — source processing")
case .afterWaiting:
print("AfterWaiting — wake up after sleep")
case .exit:
print("Exit — RunLoop terminated")
default:
break
}
}
CFRunLoopAddObserver(
CFRunLoopGetCurrent(),
observer,
.commonModes
)
}
// Example: RunLoop processes a timer on the main thread
func timerOnMainRunLoop() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
print("Tick: \(Date())")
}
// RunLoop.current.run() on Main Thread is called by UIApplicationMain
// automatically — no need to start manually
RunLoop.current.run() // This call will not return on Main Thread
}
The observeRunLoopActivities example registers an Observer on the main RunLoop that logs each phase of the cycle. This is useful for debugging: if you see a long gap between .beforeTimers and .afterWaiting, it means RunLoop is blocked by an operation on the Main Thread. timerOnMainRunLoop shows how NSTimer automatically works on the main RunLoop — when creating Timer.scheduledTimer, it adds the timer to the current RunLoop by default (.default mode).
Android Looper is an analog of RunLoop. Looper.prepare() creates a message queue (MessageQueue) on a thread, Looper.loop() starts an infinite processing loop. Handler sends messages and Runnables to this queue. The main difference: RunLoop supports modes, while Android Looper does not. Looper processes all messages without mode filtering, making it simpler but less flexible for priority scenarios (e.g., scrolling in iOS is handled in .tracking mode separately from other events).
RunLoop Mode is a set of sources, timers, and observers that are active at a given moment. Modes allow isolating event processing by priority. When a user scrolls a UITableView, RunLoop switches to .tracking mode, in which only scroll events and related timers/animations are processed. All other sources (e.g., NSURLConnection) are suspended until the scroll mode is exited.
Three main modes: .default (NSDefaultRunLoopMode) — the primary mode in which all events except scrolling are processed; .tracking (UITrackingRunLoopMode) — activated during scrolling or gesture navigation; .common (NSRunLoopCommonModes) — not a separate mode but a set of aliases that includes .default + .tracking. Adding a source to .commonModes automatically adds it to all modes in the set.
| Mode | Core Foundation Constant | Foundation Constant | When Active |
|---|---|---|---|
| .default | kCFRunLoopDefaultMode | RunLoop.Mode.default | Normal state, no scrolling |
| .tracking | UITrackingRunLoopMode | RunLoop.Mode.tracking | Scrolling, gesture recognizers |
| .common | kCFRunLoopCommonModes | RunLoop.Mode.common | Pseudo-mode: default + tracking |
| .initialRun | kCFRunLoopInitialRunRunLoopMode | — | First RunLoop launch |
A classic problem: NSTimer added to .default mode stops firing during scrolling because RunLoop switches to .tracking mode and does not process timers from .default. The solution is to add the timer to .commonModes: RunLoop.current.add(timer, forMode: .common). This makes the Timer fire in both .default and .tracking. An alternative is to use DispatchQueue.main.async instead of NSTimer, since GCD works at the thread level, not at RunLoop mode level.
Background threads do not have a RunLoop by default. If you need to run NSTimer, process NSInputStream/NSOutputStream, or respond to performSelector: on a background thread, you must manually create and start a RunLoop. Without a RunLoop, timers and performSelector: will never fire — the thread will execute the code and exit without waiting for events.
To create a RunLoop on a background thread, simply call RunLoop.current.run() at the end of the thread's work. This call blocks the thread indefinitely, processing events. To stop it, use CFRunLoopStop(CFRunLoopGetCurrent()). Important: RunLoop.current creates a RunLoop lazily on first access — if run() is not called, it will not process events. Pattern: configure sources -> add to RunLoop -> call run().
import Foundation
// Background thread with its own RunLoop
class BackgroundRunLoopManager {
private let thread: Thread
private var isRunning = false
init() {
thread = Thread { [weak self] in
// RunLoop is created automatically when RunLoop.current is called
let runLoop = RunLoop.current
// Add a port to keep RunLoop active
runLoop.add(Port(), forMode: .default)
// Start event processing
var isFinished = false
while !isFinished {
// run(mode:before:) returns true if an event was processed
isFinished = !runLoop.run(mode: .default, before: Date.distantFuture)
}
}
thread.name = "com.app.background-runloop"
}
func start() {
thread.start()
isRunning = true
}
func stop() {
// Stopping RunLoop on a background thread
self.perform(
#selector(BackgroundRunLoopManager.stopRunLoop),
on: thread,
with: nil,
waitUntilDone: false
)
}
@objc
private func stopRunLoop() {
CFRunLoopStop(CFRunLoopGetCurrent())
isRunning = false
}
}
// Usage: timer on a background RunLoop
let manager = BackgroundRunLoopManager()
manager.start()
// Sending a task to a background RunLoop via performSelector
manager.perform(
#selector(BackgroundRunLoopManager.backgroundTask),
on: manager.thread,
with: nil,
waitUntilDone: false
)
BackgroundRunLoopManager creates a background thread with a persistent RunLoop. Adding an empty Port() is necessary so that RunLoop does not terminate immediately — without sources, RunLoop.run() returns false and exits. performSelector:onThread: sends a message to the background RunLoop — it will be processed when RunLoop enters the BeforeSources phase. Stop calls CFRunLoopStop on the background thread, terminating the cycle.
NSTimer creates a timer event that RunLoop processes in the BeforeTimers phase. Timers can be repeating and non-repeating. NSTimer does not guarantee firing accuracy: if RunLoop is blocked by a long operation, the timer will fire after unblocking, and all missed firings will be coalesced into one (for repeating timers — at most one "caught up" firing).
CADisplayLink is a specialized timer synchronized with the screen refresh rate (60/120/144 Hz). It is used for animations and video updates. CADisplayLink is added to RunLoop and fires before each rendering frame (before Core Animation sends the layer for rendering). If a frame is missed (display link did not fire within 16 ms), the next call occurs in the next VSync cycle.
import UIKit
class AnimationController {
private var displayLink: CADisplayLink?
private var displayLinkTimer: Timer?
private var startTime: CFTimeInterval = 0
// CADisplayLink — animation with vsync
func startDisplayLinkAnimation() {
displayLink = CADisplayLink(target: self,
selector: #selector(step))
// Adding to .common mode — works even during scrolling
displayLink?.add(to: .current, forMode: .common)
startTime = CACurrentMediaTime()
}
@objc
private func step(displayLink: CADisplayLink) {
let elapsed = CACurrentMediaTime() - startTime
// Called every frame (60 FPS → every 16.6 ms)
print("Frame at \(elapsed) seconds")
if elapsed > 5.0 {
displayLink.invalidate() // stop after 5 seconds
}
}
// NSTimer — periodic task
func startTimerInCommonMode() {
displayLinkTimer?.invalidate()
displayLinkTimer = Timer.scheduledTimer(
withTimeInterval: 1.0,
repeats: true
) { [weak self] timer in
print("Timer tick")
}
// KEY: add to .common, otherwise the timer will freeze during scrolling
RunLoop.current.add(displayLinkTimer!, forMode: .common)
}
func stop() {
displayLink?.invalidate()
displayLinkTimer?.invalidate()
}
}
In AnimationController, CADisplayLink is added to .common mode, which ensures step is called on every frame regardless of scrolling. displayLink.add(to: .current, forMode: .common) — the standard pattern for animations that should not be interrupted during scrolling. NSTimer is also added to .common mode to tick during scrolling. Without this, the timer would only fire in .default mode.
CFRunLoopObserver is a mechanism for tracking RunLoop phases. With an Observer, you can receive notifications about mode entry, start of timer processing, start of source processing, wake-up after sleep, and mode exit. Observers are used by frameworks for their own needs: Core Animation uses them for rendering layers before RunLoop goes to sleep, UIKit — for updating layout after event processing.
A developer can also add Observers for their own purposes. For example: measuring event processing time (profiling), executing deferred operations before RunLoop goes to sleep (when the UI is already updated and the user is not interacting), auto-saving data during prolonged inactivity. An Observer is registered via CFRunLoopAddObserver with a mode and a bitmask of tracked activities.
The most useful Observer points: .afterWaiting — executed after RunLoop wakes up and can contain code that should run after event processing; .beforeTimers — before timer processing, allows measuring the time elapsed since the previous processing; .exit — fires when RunLoop stops, useful for cleaning up background thread resources.
CFRunLoopStop is a function that forcibly terminates the current RunLoop iteration. When CFRunLoopStop(CFRunLoopGetCurrent()) is called, RunLoop finishes processing the current event and exits run(), returning false. This is the standard way to stop a RunLoop on a background thread. On the Main Thread, CFRunLoopStop is not recommended — the main RunLoop should run for the entire lifetime of the app. For background threads, after CFRunLoopStop, the thread can either terminate or continue executing the code after run().
Frequently Asked Questions
RunLoop is an event processing cycle in iOS, implemented by CFRunLoop (Core Foundation) and NSRunLoop (Foundation). It waits for events (touches, timers, input sources) and dispatches them to handlers on the thread. Each thread can have one RunLoop, but it is automatically created only for the Main Thread. RunLoop manages modes (.default, .tracking, .common), isolating processing by priority.
NSTimer is added to the .default RunLoop mode by default. When the user scrolls, RunLoop switches to .tracking mode and does not process timers from .default. Solution: add the timer to .common mode via RunLoop.current.add(timer, forMode: .common). .common combines .default and .tracking, so the timer fires in both modes.
Only if the background thread uses timers (NSTimer), performSelector:onThread:, NSInputStream/NSOutputStream, or source events. If the thread performs a synchronous task (file download, computations) and exits — RunLoop is not needed. To start, call RunLoop.current.run() after configuring sources. To stop — CFRunLoopStop(CFRunLoopGetCurrent()).
RunLoop works at the thread level and processes events sequentially with mode support. DispatchQueue is a thread pool abstraction — tasks are executed on any available thread. GCD does not support modes and lives independently of RunLoop. DispatchQueue.main uses the main RunLoop to execute blocks — this is the only point of intersection. For background tasks, GCD is preferred.
CADisplayLink is a timer synchronized with VSync (screen refresh rate). It is added to RunLoop and fires before each rendering frame in the BeforeTimers phase. CADisplayLink only works on the Main Thread, since screen rendering happens there. For continuous animations during scrolling, add it to .common mode: displayLink.add(to: .current, forMode: .common).
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also