iOS Performance & Memory
ARC, retain cycles, Instruments, main thread, image and table optimization
- 01
How does ARC (Automatic Reference Counting) work in Swift?
MediumARC is a compile-time mechanism: the compiler inserts the retain and release calls, so an object is deallocated the instant its last strong reference goes away, with no collector and no pause.
class User { let name: String init(name: String) { self.name = name } deinit { print("\(name) freed") } } … - 02
What is a retain cycle? How do you detect and prevent them?
MediumA retain cycle is two (or more) reference-counted objects holding strong references to each other so refcount never hits zero.
// Bug: store result of Combine sink without [weak self] class VM: ObservableObject { @Published var items: [String] = [] private var bag = Set<AnyCancellable>() init() { api.publisher … - 03
How do you profile an iOS app with Instruments?
MediumInstruments is Apple's profiler suite, attached to a running app from Xcode with Product → Profile.
import OSLog let signposter = OSSignposter(subsystem: "com.example.app", category: "feed") func fetchFeed() async throws -> [Post] { let state = signposter.beginInterval("fetchFeed") … - 04
How do you keep the main thread responsive in iOS apps?
MediumThe main thread draws every frame and handles every touch, so anything slow there is visible: over roughly 16ms you drop a frame, and over 250ms the system calls it a hang.
// ❌ Blocking the main thread @MainActor func loadFeedBadly() { let data = try? Data(contentsOf: hugeURL) // synchronous I/O on main feed = (try? JSONDecoder().decode([Post].self, from: data!)) ?? [] } … - 05
How do you optimize UITableView / UICollectionView scrolling performance?
HardSmooth scrolling means every frame's work fits in the frame budget: about 16ms at 60Hz, 8ms on a 120Hz ProMotion display.
// Pre-decode image cache final class ImageCache { static let shared = ImageCache() private let cache = NSCache<NSURL, UIImage>() func image(for url: URL) async -> UIImage? { if let img = cache.object(forKey: url as NSURL) { return img } … - 06
How do you analyze and improve app launch time?
HardApp launch splits into two measurable phases with different fixes: pre-main work done by dyld and the runtime, and everything from main until your first useful frame.
import OSLog let signposter = OSSignposter(subsystem: "com.example.app", category: "launch") @main struct MyApp: App { … - 07
The photo grid crashes with no stack trace and the app dies in the background. What is going on?
HardThat is not a crash but a memory kill: iOS terminates a process whose footprint crosses the device limit, and it arrives as a jetsam event rather than as a crash report pointing at your code.
func thumbnail(from url: URL, maxPixel: CGFloat, scale: CGFloat) -> UIImage? { let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { return nil } let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, … - 08
A SwiftUI list stutters while scrolling. How do you find the cause and what usually fixes it?
HardFind out what is re-running before changing anything: the SwiftUI instrument counts body evaluations and the Animation Hitches instrument shows which frames missed their deadline.
// Slow row: work in body, unstable identity, full-size image struct BadRow: View { let post: Post var body: some View { let formatter = DateFormatter() // rebuilt on every body call formatter.dateStyle = .medium … - 09
QA says the app drains the battery. What do you actually change?
MediumEnergy cost is driven by how often you wake the radio, the GPU and the CPU rather than by how much code runs, so the fixes are batching and scheduling, not micro-optimisation.
// Batch events; flush on background or when the buffer fills actor AnalyticsQueue { private var buffer: [Event] = [] func track(_ event: Event) async { buffer.append(event) … - 10
The app is 300 MB in the store and installs are dropping. Where does the size go?
MediumStart from the App Store Connect size report, which gives download and install size per device class, because the size of the archive on your Mac is not what a user actually downloads.
// On-Demand Resources: tagged content is downloaded on first use final class TutorialAssets { private var request: NSBundleResourceRequest? func load() async throws -> URL { let request = NSBundleResourceRequest(tags: ["tutorial-videos"]) … - 11
What is thread explosion, and how do you get priorities right under Swift Concurrency?
HardThread explosion happens when blocked work keeps arriving on a concurrent queue and GCD spawns a fresh thread for every blocked one.
// Thread explosion: 200 blocked threads, none of them making progress for url in urls { DispatchQueue.global().async { let data = try? Data(contentsOf: url) // blocks a thread for the whole transfer handle(data) } … - 12
How do you stop a performance regression from ever reaching users?
MediumTurn the numbers you care about into a gate and a dashboard: a measured budget in CI catches the regression before release, and MetricKit tells you what real devices see after it.
import XCTest final class LaunchPerformanceTests: XCTestCase { func testColdLaunch() { measure(metrics: [XCTApplicationLaunchMetric(), XCTMemoryMetric()]) { XCUIApplication().launch() … - 13
Appending one row to a 20 000-element array inside a model struct takes 40ms and gets slower every time. What is Swift copying?
MediumSomething else is holding a reference to the array's storage at the moment you mutate it, so copy-on-write duplicates all 20 000 elements instead of appending in place.
struct Row { let id: UUID; var flag: Bool } // Slow: the undo entry shares the buffer, so the next append copies all of it final class SlowEditor { private(set) var rows: [Row] = [] private var undo: [[Row]] = [] … - 14
The user pops the screen, but requests keep firing and the view model's deinit never runs. What did that Task do?
MediumAn unstructured
Task { }holds a strong reference to everything its body captured and is cancelled by nobody, so the view model stays alive and its work stays scheduled until the body actually returns.// Leaks: strong self, unstructured, and the loop never ends final class BadModel { var posts: [Post] = [] init(feed: Feed) { Task { … - 15
A crash report shows exception code 0x8badf00d and no crashing frame in your code. What actually killed the app?
MediumThe watchdog killed it: the app failed to finish a lifecycle transition inside its time budget, so the system sent
SIGKILLand stamped the report0x8badf00d.// Ships a watchdog kill: launch parks the main thread on the network func application(_ app: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let semaphore = DispatchSemaphore(value: 0) var remote: Config? RemoteConfig.fetch { remote = $0; semaphore.signal() } … - 16
The app is completely frozen — no taps, no scrolling — yet the spinner keeps spinning smoothly. Why?
MediumBecause your main thread does not draw that animation: it described it once, and a separate process — the render server — interpolates every frame from that description.
// One animation description, handed to the render server once let spinner = UIActivityIndicatorView(style: .medium) spinner.startAnimating() // Blocking main does not stop it: taps die, the spinner keeps going Thread.sleep(forTimeInterval: 5) … - 17
Time Profiler shows the main thread idle at 20%, yet the card list still drops frames on an older phone. Where is the time going?
HardNot in your code — the frames are being lost after the commit, in the render server and on the GPU, which is exactly what a CPU sampler cannot see.
// Expensive cell: an offscreen pass for the shadow, another for the mask final class BadCardCell: UICollectionViewCell { let photo = UIImageView() func style() { layer.shadowOpacity = 0.2 … - 18
Xcode's memory gauge says 480 MB, Instruments Allocations says 90 MB, and the app still gets jetsammed. Which number is real?
HardThe gauge is: jetsam charges you
phys_footprint, which is dirty plus compressed memory including mappings Allocations never shows, while Allocations only reports the heap you got through malloc and the Swift and Objective-C runtimes.import Foundation import os // The number jetsam actually uses, readable from inside the app func physFootprint() -> UInt64 { var info = task_vm_info_data_t() … - 19
The hottest frames in your Time Profiler trace are swift_retain, swift_release and objc_msgSend. What is the app really spending its time on?
HardPer-object bookkeeping in a hot loop: atomic reference counting and dynamic dispatch that the optimiser was unable to remove because it could not see enough of the code to prove it was safe.
// Slow shape: boxed existentials, an open class, retain traffic per element class Shape { // subclassable -> vtable dispatch func area() -> Double { 0 } } func totalArea(_ shapes: [any Renderable]) -> Double { // boxed + witness table … - 20
Memory climbs 4 MB a minute on a screen the user is only watching, and the Leaks instrument finds nothing. What is growing?
HardA buffer with no bound: a producer is outrunning its consumer and the queued values are perfectly reachable, so a leak detector — which only reports memory with no path from a root — has nothing to say about them.
// Unbounded by default: a fast producer queues everything the consumer misses let (readings, continuation) = AsyncStream.makeStream(of: Reading.self) // Bounded: keep the freshest value, drop the rest — what a UI consumer wants let (latest, feed) = AsyncStream.makeStream( of: Reading.self, …