RunLoop in iOS: What It Is, Modes of Operation, and Event Cycle

Author: IT Sectr Published: 2026-03-16 Reading time: 11 min

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 — an event loop that processes events on a thread: touches, timers, input sources
  • Main Thread has an automatic RunLoop (CFRunLoopGetMain()), background threads — need to start manually
  • Three modes: .default (primary), .tracking (scrolling), .common (combines default + tracking)
  • NSTimer and CADisplayLink do not work without an active RunLoop on the thread
  • RunLoop observers allow reacting to mode entry/exit and start/end of processing

What is RunLoop

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.

How RunLoop Works: Anatomy of the Event Cycle

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.

swift
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).

RunLoop vs Looper (Android)

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 Modes: default, tracking, common

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.

ModeCore Foundation ConstantFoundation ConstantWhen Active
.defaultkCFRunLoopDefaultModeRunLoop.Mode.defaultNormal state, no scrolling
.trackingUITrackingRunLoopModeRunLoop.Mode.trackingScrolling, gesture recognizers
.commonkCFRunLoopCommonModesRunLoop.Mode.commonPseudo-mode: default + tracking
.initialRunkCFRunLoopInitialRunRunLoopModeFirst RunLoop launch

Why NSTimer Does Not Fire During Scrolling

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.

RunLoop in Background Threads

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().

swift
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.

swift
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.

RunLoop Observers: Monitoring the Cycle Events

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 and Cycle Termination

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

What is RunLoop in iOS?

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.

Why does NSTimer not work during scrolling?

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.

Do I need to start a RunLoop on a background thread?

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()).

How is RunLoop different from GCD DispatchQueue?

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.

How is CADisplayLink related to RunLoop?

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

  • RunLoop — iOS event loop: processes touches, timers, input sources on a thread; Main Thread has an automatic RunLoop
  • Three modes: .default (general), .tracking (scrolling), .common (default + tracking) — control event filtering
  • NSTimer in .default does not fire during scrolling — solution: add to .common mode via RunLoop.current.add(timer, forMode: .common)
  • Background threads do not have a RunLoop by default — for timers and performSelector: manual startup via RunLoop.current.run() is required
  • CADisplayLink — a timer for every VSync frame, essential for smooth animations; add to .common to work during scrolling
  • RunLoop Observer — monitors phases: Entry, BeforeTimers, BeforeSources, AfterWaiting, Exit; used for profiling
  • RunLoop ≠ Looper: iOS RunLoop supports modes and timers, Android Looper is simpler — no modes, Handler + MessageQueue

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.

Discuss the project

Read also