Run Loops

Explain what a run loop is and why it keeps a thread alive and responsive between events, distinguish NSRunLoop from the underlying CFRunLoop, reason correctly about run loop modes (and the classic scrolling-pauses-my-timer gotcha), and keep a worker thread alive with its own run loop for persistent input sources like streams and ports.

A running iOS or macOS app does not execute main(), finish, and exit the way a small command-line C program does — it stays alive indefinitely, sitting idle between touches, timer firings, and network callbacks, then springing into action to handle each one as it arrives, and then going quiet again. The mechanism that makes this possible on any given thread is the run loop: a loop, provided by the system, that a thread enters and that keeps that thread parked, asleep, and using essentially no CPU, until there is actual work to do — at which point it wakes the thread, dispatches that one piece of work, and goes back to sleep waiting for the next. This lesson covers NSRunLoop and its underlying CFRunLoop counterpart, the main run loop every UIKit/AppKit app already has running for you, run loop modes and the very common gotcha they cause with UI interaction, and why a plain background thread you spawn yourself does NOT get this behavior automatically.

What a Run Loop Actually Is: the Wait/Wake/Process Cycle

Strip away all the API surface and a run loop is a genuinely simple idea: a loop that, on each pass, checks whether there is any pending work for the thread it belongs to (an input source ready to fire, a timer whose fire date has arrived, a message waiting on a port), and if there is none, puts the thread to sleep — a real, OS-level sleep that consumes essentially no CPU — until something wakes it back up. When something does wake it (an event arrives), the run loop processes exactly that event, running whatever callback or handler is associated with it, and then loops back around to check again, going back to sleep if there's nothing else pending.

This is precisely why a UIKit app's main thread does not exit the instant application:didFinishLaunchingWithOptions: returns: at startup, UIKit runs the main run loop (effectively forever, for the life of the app) instead of letting main() simply return, and every touch, every timer, every network callback that gets delivered to the main thread arrives as one more piece of work handled by one more pass through that already-running loop. Without a run loop keeping it parked and listening, the main thread would finish its startup code and the process would simply terminate — there would be nothing left keeping it around to respond to the next touch.

It is worth being precise that a run loop is not itself a thread — every thread that wants this 'stay alive and wait for events' behavior needs a run loop associated with it and needs to actually be told to run it; a run loop is a facility a thread can opt into using, not something threads get automatically just by existing.

• A run loop repeatedly checks for pending work, sleeps (near-zero CPU) if there's none, wakes and processes exactly one event when something arrives, then repeats. • This wait/wake/process cycle is what keeps a UIKit/AppKit app's main thread alive and responsive between touches, timers, and callbacks, instead of exiting after startup. • UIKit runs the main run loop for you, effectively for the entire life of the app, starting right after your launch code finishes. • A run loop is a facility a thread opts into and must be told to run — it is not automatically granted to every thread just because the thread exists.

Run Loop Anatomy: Input Sources, Timers, and Ports

A run loop's job is to wait on and dispatch three broad categories of work, corresponding directly to the diagram below. Input sources are event sources that deliver asynchronously — most commonly, in real apps, this means events delivered from another thread or process, such as UIKit's own event-delivery machinery feeding touch events to the main run loop, or a custom source you set up yourself with CFRunLoopSource to signal work from a background thread back to a specific run loop. Timers (NSTimer, or the lower-level CFRunLoopTimer it's built on) are scheduled to fire at or after a specific point in time — once, or repeatedly at a fixed interval — and a timer only actually fires while the run loop it was scheduled on is running and processing that timer's mode, which is the seed of the scrolling gotcha covered in its own section below.

Ports (NSPort, or a Mach port at the lowest level, and NSStream when working with sockets/files asynchronously) are how a run loop is told about data arriving from another thread, process, or the network without that thread having to constantly poll — the sender writes to the port or stream, and the run loop on the receiving thread wakes up exactly when there is something to read, dispatching to whatever handler is registered for it. This is the general shape every run loop-driven API in Foundation follows: register some source of future work with a specific run loop (a timer, a port, a stream, or a custom input source), and the run loop takes responsibility for waking the thread and calling your code back at the right moment, rather than your code ever needing to sit in a manual polling loop.

• Input sources: asynchronous event sources — UIKit's own touch-event delivery, or a custom CFRunLoopSource signaled from another thread. • Timers (NSTimer / CFRunLoopTimer): fire once or repeatedly at scheduled times, but ONLY while the run loop they're scheduled on is actually running in a matching mode. • Ports and streams (NSPort, Mach ports, NSStream): wake the run loop when data arrives from another thread, process, or the network, avoiding manual polling. • The common pattern across all three: register a future source of work with a run loop once, and let the run loop wake your thread and invoke your callback at the right moment.

The Run Loop, Visually

The diagram traces one full pass of a run loop: it has three kinds of things it can be watching for (input sources, timers, and ports), and when any of them has something ready, the run loop processes it, then goes back to waiting/sleeping until the next event — a cycle that repeats for as long as the run loop keeps running.

NSRunLoop vs. CFRunLoop

NSRunLoop is the Objective-C, Foundation-level class you interact with day to day — [NSRunLoop currentRunLoop] gets the run loop for the calling thread (creating one if it doesn't already exist), [NSRunLoop mainRunLoop] specifically gets the main thread's run loop, and its methods (run, runUntilDate:, addTimer:forMode:) are what most Objective-C code actually calls. But NSRunLoop is a thin, toll-free-bridged Objective-C wrapper around a genuinely lower-level Core Foundation object, CFRunLoopRef, which is the actual C-level implementation doing the real work — scheduling sources, tracking modes, coordinating with the operating system's underlying wait/wake primitives. This relationship is exactly the same shape covered in the dedicated 'Core Foundation' and 'Toll-Free Bridging' modules: NSRunLoop and CFRunLoopRef are, at the implementation level, the same underlying object viewed through two different API surfaces, one Objective-C, one plain C.

In practice this matters for one concrete reason: some lower-level or more advanced run loop capabilities (certain observer callbacks, finer-grained control over run loop activity notifications via CFRunLoopObserver) are only exposed through the CFRunLoop C API, not through NSRunLoop's more limited Objective-C surface. Day-to-day app code overwhelmingly uses NSRunLoop, but knowing that CFRunLoopRef exists underneath — and that you can drop down to it with [[NSRunLoop currentRunLoop] getCFRunLoop] when you need a capability NSRunLoop doesn't expose — is exactly the kind of Core-Foundation-adjacent knowledge the toll-free bridging material builds toward.

• NSRunLoop: the Objective-C, Foundation-level API — [NSRunLoop currentRunLoop], [NSRunLoop mainRunLoop], run/runUntilDate:/addTimer:forMode:. • CFRunLoopRef: the underlying Core Foundation C-level object actually implementing the run loop; NSRunLoop is a toll-free-bridged wrapper around it. • Same relationship covered generally in 'Core Foundation' and 'Toll-Free Bridging' — two API surfaces (ObjC and C) over the same underlying object. • Some finer-grained capabilities (like CFRunLoopObserver activity notifications) are only exposed through the CFRunLoop C API, reachable from NSRunLoop via getCFRunLoop.

Run Loop Modes, and Why a Timer 'Pauses' While You Scroll

A run loop does not process every scheduled source, timer, and port on every pass indiscriminately — it processes only the ones registered for whichever single mode it is currently running in, and a mode is really just a named filter/grouping mechanism: sources and timers are each registered with one or more specific modes, and the run loop, when told to run in a given mode, only pays attention to the sources and timers registered for that exact mode, ignoring everything registered only under a different mode. Two modes matter most in everyday UIKit/AppKit development: NSDefaultRunLoopMode (also written kCFRunLoopDefaultMode), the ordinary idle-time mode most app code runs in most of the time, and UITrackingRunLoopMode, a special mode the system switches the main run loop into specifically while the user is actively dragging a scroll view, tracking a control, or otherwise mid-interaction with certain UIKit tracking gestures.

This is the source of a genuinely common, confusing real-world bug: a plain [NSTimer scheduledTimerWithTimeInterval:...] call schedules the new timer only in the default mode (NSDefaultRunLoopMode) by convention. The instant the user starts scrolling a UIScrollView (or dragging a slider, or interacting with several other UIKit tracking-driven controls), the main run loop switches to UITrackingRunLoopMode for the duration of that gesture — and while it's in that mode, it is not processing anything registered only under NSDefaultRunLoopMode, so the timer appears to simply pause, silently, firing zero times until the user lets go and the run loop switches back to the default mode, at which point the timer resumes as if nothing happened. This is not a bug in NSTimer — it is entirely intentional, correct behavior of run loop modes doing exactly their job — but it consistently surprises developers who haven't internalized that a timer's mode registration, not just its fire interval, determines when it actually fires.

The fix is to register the timer (or any source that needs to keep firing during scrolling) with NSRunLoopCommonModes instead of relying on the default mode alone — NSRunLoopCommonModes is a standing alias/group that both NSDefaultRunLoopMode and UITrackingRunLoopMode are members of, so a source registered against it keeps firing in either mode, unaffected by which specific mode the run loop happens to be running in at that moment. Concretely, this means creating the timer with the plain init form and adding it explicitly ([[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];) instead of the one-line scheduledTimerWithTimeInterval: convenience method, which only ever registers for the default mode.

• A run loop, at any moment, is running in exactly one mode, and only processes sources/timers registered for that specific mode. • NSDefaultRunLoopMode: the ordinary idle-time mode most app code runs in most of the time. • UITrackingRunLoopMode: the mode the main run loop switches into while the user is actively scrolling/dragging/tracking a UIKit control. • A plain scheduledTimerWithTimeInterval:... timer registers only for the default mode, so it appears to silently pause during scrolling — this is intentional mode behavior, not an NSTimer bug. • Fix: register the timer for NSRunLoopCommonModes (a group both default and tracking modes belong to) via addTimer:forMode:, so it keeps firing during scrolling too.

Worker-Thread Run Loops: Staying Alive on Purpose

The main thread's run loop is already running for you, courtesy of UIKit/AppKit — but a plain background thread you spawn yourself (via NSThread, or a raw pthread) gets no such favor: its run loop, if it even bothers to create one via [NSRunLoop currentRunLoop], is not automatically told to run, and a thread whose entry function simply returns terminates immediately, taking any not-yet-fired timers or unprocessed input sources registered on it down with it. This is different from, and not solved by, GCD/dispatch queues, which are designed around the opposite model — short, discrete blocks of work pulled from a queue and executed, with no expectation that any one thread stays alive persistently waiting on scheduled input.

A background thread that genuinely needs to stay alive persistently — most commonly because it owns a long-lived NSStream (a socket connection, a file handle being read asynchronously) or an NSPort-based communication channel with another thread — must explicitly keep its run loop running, typically by scheduling at least one input source or timer on it and then calling [[NSRunLoop currentRunLoop] run] (or runUntilDate: in a loop, or runMode:beforeDate: in a manual polling loop that also checks a stop flag). Without at least one source or timer scheduled on it, run returns immediately rather than actually blocking — a run loop with nothing registered on it has nothing to wait for, so it considers itself already 'done' and exits right back out, which is a subtle trap: adding the persistent source or timer first, then calling run, is required, not optional or automatic.

This pattern shows up concretely whenever you build a long-lived networking helper around raw NSStream (rather than a higher-level API like URLSession that already manages its own internal run loop machinery for you): you create a dedicated background thread, schedule the input stream and output stream on that thread's run loop with scheduleInRunLoop:forMode:, and then call run on that thread's run loop to keep the thread alive indefinitely, processing stream-ready callbacks as bytes arrive or become writable, for as long as the connection needs to stay open.

• A background thread's run loop is not automatically run — a thread whose function simply returns terminates immediately, silently losing anything scheduled on its unrun run loop. • GCD/dispatch queues are a different model entirely (short discrete work items pulled from a queue) and do not by themselves keep a thread alive listening for scheduled input sources. • A thread that must stay alive persistently (owning a long-lived NSStream or NSPort) needs to schedule at least one source/timer on its run loop, then explicitly call run (or runUntilDate:/runMode:beforeDate:). • A run loop with nothing scheduled on it returns immediately from run — you must add the persistent source first, or run exits right away with nothing to show for it. • Concretely: schedule input/output NSStreams on a dedicated background thread's run loop with scheduleInRunLoop:forMode:, then call run on that thread to keep it alive for the life of the connection.

Cross-References: GCD, Concurrency, Timers, and Streams

Run loops and Grand Central Dispatch solve related but distinct problems, and the dedicated 'Grand Central Dispatch' and 'Concurrency' modules go deep on GCD's own model: GCD is fundamentally about scheduling and executing discrete units of work (blocks) onto a pool of managed threads, optimized for throughput and not tied to any one specific thread staying alive; a run loop is fundamentally about one specific, already-existing thread waiting efficiently for a sequence of future events over time. In practice the two often coexist in the same app without conflict — GCD handles most 'do this work now, off the main thread' needs, while the main run loop (and the occasional dedicated worker-thread run loop, for the NSStream/NSPort case covered above) handles the 'wait indefinitely and react to events as they arrive' needs — and understanding which model actually fits a given problem is exactly the judgment this lesson and the GCD/Concurrency modules together are meant to build.

The sibling addendum modules 'Timers' and 'Streams' each go deeper on their own specific API (NSTimer's own full API surface including tolerance and invalidation; NSStream's own delegate-callback model and byte-level API) — this lesson deliberately covers only the run loop-specific angle of each: how a timer's mode registration determines when it fires, and how a stream-owning background thread must keep its run loop alive to receive stream callbacks at all. Read this lesson first for the run loop mental model, then the Timers and Streams modules for the full depth of each individual API built on top of it.

• GCD ('Grand Central Dispatch', 'Concurrency' modules): scheduling discrete work blocks onto a managed thread pool, not tied to any one thread staying alive. • Run loops: one specific, already-alive thread waiting efficiently and repeatedly for a sequence of future events over time. • The two coexist in the same app: GCD for 'do this work now, off-thread'; run loops for 'wait indefinitely and react as events arrive'. • 'Timers' (sibling module): NSTimer's full API depth — this lesson covers only how mode registration determines whether a timer fires. • 'Streams' (sibling module): NSStream's full delegate/byte-level API depth — this lesson covers only why a stream-owning thread must keep its run loop running.

Code Examples

[NSRunLoop currentRunLoop] returns (creating if necessary) the run loop object associated with whichever thread calls it. runUntilDate: is a bounded way to run the loop — useful for a background thread that only needs to stay alive for a known window, unlike the unbounded 'run forever' pattern used for UIKit's own main run loop or a persistent stream-handling thread.

[runLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:5.0]];

This is the mode gotcha made concrete: nothing about this code is wrong syntactically or semantically for a non-scrolling screen, and the timer fires exactly as expected right up until the user starts a scroll gesture — at which point it goes silent with zero errors or warnings, purely because of which mode it was registered under, resuming automatically once scrolling ends.

Common mistake: Assuming scheduledTimerWithTimeInterval:... keeps firing no matter what the user is doing on screen — it only fires while the run loop is in the specific mode (NSDefaultRunLoopMode) that method registers it under.

timerWithTimeInterval:target:selector:repeats: (note: no 'scheduled' prefix) creates a timer WITHOUT scheduling it on any run loop yet. addTimer:forMode: then explicitly registers it under NSRunLoopCommonModes — a standing group both NSDefaultRunLoopMode and UITrackingRunLoopMode belong to — so this timer keeps firing during scrolling as well as during idle time, fixing the exact bug in the previous example.

[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

This thread opens two streams and then its entry method simply returns, ending the thread. Because nothing scheduled the streams on a run loop and called run, there is no mechanism keeping this thread alive to actually receive NSStreamDelegate callbacks as bytes arrive — the streams were opened and then immediately orphaned when the thread exited.

Common mistake: Assuming that opening a stream (or otherwise starting async work) on a background thread is enough by itself — without running that thread's run loop, the thread exits and takes the in-progress work with it.

scheduleInRunLoop:forMode: registers both streams as input sources on this thread's own run loop before opening them. Because at least one source is now scheduled, runMode:beforeDate: in the while loop actually blocks the thread (rather than returning immediately), keeping it alive indefinitely to process delegate callbacks as bytes arrive, until _keepRunning is set to false by some other part of the code to end the loop cleanly.

[inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];

NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
NSLog(@"Current mode: %@", [runLoop currentMode]);

// Runs the run loop for (up to) 5 seconds, processing any sources/timers
// scheduled on it in the default mode during that window, then returns.
[runLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:5.0]];
NSLog(@"5 seconds of run loop activity have passed (or all sources finished sooner)");
// Scheduled with the one-line convenience method:
[NSTimer scheduledTimerWithTimeInterval:1.0
                                  target:self
                                selector:@selector(tick)
                                repeats:YES];

// While a user is actively scrolling a UIScrollView on screen,
// 'tick' silently stops being called — no crash, no error, no log —
// because scheduledTimerWithTimeInterval:... registers the timer
// ONLY for NSDefaultRunLoopMode, and the main run loop switches to
// UITrackingRunLoopMode for the duration of the scroll gesture.
NSTimer *timer = [NSTimer timerWithTimeInterval:1.0
                                          target:self
                                        selector:@selector(tick)
                                        repeats:YES];

[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
- (void)startBackgroundWork {
    [NSThread detachNewThreadSelector:@selector(threadEntry) toTarget:self withObject:nil];
}

- (void)threadEntry {
    NSInputStream *inputStream;
    NSOutputStream *outputStream;
    [NSStream getStreamsToHostWithName:@"example.com" port:443
                            inputStream:&inputStream outputStream:&outputStream];
    [inputStream open];
    [outputStream open];
    // Thread function returns HERE — no run loop was ever run on this thread,
    // so this thread terminates almost immediately, and the just-opened
    // streams never get a chance to deliver any data or callbacks.
}
- (void)threadEntry {
    NSInputStream *inputStream;
    NSOutputStream *outputStream;
    [NSStream getStreamsToHostWithName:@"example.com" port:443
                            inputStream:&inputStream outputStream:&outputStream];
    inputStream.delegate = self;
    outputStream.delegate = self;

    [inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
    [outputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
    [inputStream open];
    [outputStream open];

    // Now that a source (the streams) is scheduled, run actually blocks
    // and keeps this thread alive, dispatching NSStreamDelegate callbacks
    // as data arrives, until _keepRunning is toggled false elsewhere.
    while (_keepRunning) {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
                                  beforeDate:[NSDate distantFuture]];
    }
}

One Pass Through a Run Loop

A run loop watches three categories of sources at once — Input Sources, Timers, and Ports — for whichever mode it is currently running in. Whichever one has something ready gets Processed (its callback runs), then the run loop goes back to Wait/Sleep, using essentially no CPU, until the Next Event arrives and the cycle repeats.

Run Loop
             │
     ┌───────┼────────┐
     ↓       ↓        ↓
 Input     Timers    Ports
 Sources
     └───────┼────────┘
             ↓
          Process
             ↓
       Wait / Sleep
             ↓
         Next Event

Where Run Loops Show Up in Real App Code

Most app developers never call NSRunLoop's run methods directly, because UIKit already runs the main run loop for them and most asynchronous work goes through URLSession, GCD, or Combine/async-await, all of which manage their own scheduling internally — but the run loop mental model underneath still explains real, observable app behavior: a countdown timer or polling NSTimer that mysteriously 'freezes' while a user scrolls a list is a run loop mode bug in disguise, and it's an extremely common one reported by developers who have never heard the term 'run loop mode' before tracking it down. Direct, manual run loop management (scheduling a stream, calling run explicitly on a dedicated thread) shows up specifically in lower-level networking code that talks to raw sockets via NSStream, in some Bluetooth/accessory communication code, and in certain audio/video processing pipelines that need a persistent thread listening for hardware or port-driven callbacks — comparatively rare in typical CRUD-app code, but exactly the kind of infrastructure code a senior engineer is expected to be able to read and reason about correctly.

[NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(tick) repeats:YES]; // timer silently stops firing while the user scrolls a UIScrollView

NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(tick) repeats:YES]; [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

scheduledTimerWithTimeInterval:... only ever registers the new timer under NSDefaultRunLoopMode. The main run loop switches to UITrackingRunLoopMode while the user is scrolling or dragging a UIKit tracking control, and a timer registered only under the default mode simply does not fire during that window — silently, with no error. Registering it under NSRunLoopCommonModes instead (a group both modes belong to) keeps it firing during both idle time and active scrolling/tracking.

Why doesn't a UIKit app's main thread simply exit right after application:didFinishLaunchingWithOptions: returns? A. UIKit spawns a completely new thread for every touch event, so the original main thread is no longer needed B. The main thread is running its run loop, which keeps it alive, asleep between events, and ready to process the next touch, timer, or callback as it arrives C. iOS forbids any thread from ever exiting once launched D. The app is actually re-launched from scratch after every single event

B. The main thread is running its run loop, which keeps it alive, asleep between events, and ready to process the next touch, timer, or callback as it arrives — UIKit runs the main run loop for the life of the app. Its wait/wake/process cycle is exactly what keeps the main thread parked (using minimal CPU) and ready to handle the next event, instead of the thread finishing its startup code and the process terminating.

What is the relationship between NSRunLoop and CFRunLoopRef? A. They are unrelated, competing implementations of run loops B. NSRunLoop is the Objective-C, Foundation-level wrapper (via toll-free bridging) around the same underlying object that CFRunLoopRef represents at the Core Foundation C level C. CFRunLoopRef is deprecated and replaced entirely by NSRunLoop D. NSRunLoop only works on the main thread; CFRunLoopRef is required for background threads

B. NSRunLoop is the Objective-C, Foundation-level wrapper (via toll-free bridging) around the same underlying object that CFRunLoopRef represents at the Core Foundation C level — NSRunLoop and CFRunLoopRef are, at the implementation level, the same underlying run loop object exposed through two API surfaces — one Objective-C (NSRunLoop), one plain C (CFRunLoopRef) — exactly the toll-free bridging relationship covered in the Core Foundation module.

A repeating NSTimer created with scheduledTimerWithTimeInterval:target:selector:repeats: appears to stop firing while the user scrolls a UIScrollView, then resumes once they stop. What causes this? A. NSTimer is inherently unreliable and randomly skips firings under CPU load B. scheduledTimerWithTimeInterval:... only registers the timer under NSDefaultRunLoopMode, and the main run loop switches to UITrackingRunLoopMode during active scrolling, where that timer isn't registered C. Scrolling automatically invalidates every NSTimer in the app D. The timer's fire interval is being silently changed by UIScrollView

B. scheduledTimerWithTimeInterval:... only registers the timer under NSDefaultRunLoopMode, and the main run loop switches to UITrackingRunLoopMode during active scrolling, where that timer isn't registered — This is the classic run loop mode gotcha: the timer is only registered for NSDefaultRunLoopMode, and the run loop is running in a different mode (UITrackingRunLoopMode) during the scroll gesture, so it does not process that timer until the mode switches back.

What does NSRunLoopCommonModes solve, and how? A. It disables run loop modes entirely so everything always fires B. It is a standing group that both NSDefaultRunLoopMode and UITrackingRunLoopMode belong to, so a timer (or other source) registered against it keeps firing in either mode C. It forces the run loop to always stay in UITrackingRunLoopMode D. It is only usable on background threads, not the main thread

B. It is a standing group that both NSDefaultRunLoopMode and UITrackingRunLoopMode belong to, so a timer (or other source) registered against it keeps firing in either mode — NSRunLoopCommonModes is a mode-group alias that both the default and tracking modes are members of. Registering a timer under it (via addTimer:forMode:) means it fires whether the run loop happens to be in the default mode or the tracking mode at that moment.

A background thread opens an NSStream, sets its delegate, but never schedules it on a run loop or calls run. What actually happens? A. The stream delivers all of its delegate callbacks on the main thread automatically instead B. The thread's entry function finishes and the thread terminates almost immediately, orphaning the stream before it can deliver any callbacks C. NSStream automatically creates and runs its own dedicated run loop internally with no setup required D. The app crashes immediately when open is called

B. The thread's entry function finishes and the thread terminates almost immediately, orphaning the stream before it can deliver any callbacks — A plain background thread's run loop is not automatically run. Without scheduling at least one source (like the stream) and calling run to keep the thread alive, the thread's function simply finishes and the thread exits, leaving the just-opened stream with no live run loop left to deliver its callbacks through.

You have a UITableView displaying live-updating stock prices via a repeating NSTimer that polls a local cache every second and calls reloadData. Users report the displayed prices appear to 'freeze' whenever they scroll the table, resuming only after they lift their finger. Diagnose the cause and fix it.

Replace [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(refreshPrices) repeats:YES] with the two-step form: create the timer with [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(refreshPrices) repeats:YES], then explicitly schedule it with [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]. NSRunLoopCommonModes includes both NSDefaultRunLoopMode and UITrackingRunLoopMode, so the timer keeps firing (and reloadData keeps getting called) during active scrolling instead of appearing to freeze.

This is the exact scrolling/mode gotcha covered in this lesson. Check how the timer was created — scheduledTimerWithTimeInterval:... registers only under NSDefaultRunLoopMode, and UIScrollView (which UITableView is built on) switches the main run loop to UITrackingRunLoopMode during a scroll gesture.