DevelopmentAll levels

iOS / Swift

iOS development means building iPhone and iPad apps in Swift, a compiled language that frees memory by counting references instead of running a garbage collector. That one choice explains the platform’s most common silent bug — two objects keeping each other alive forever — and most of what a native iOS team designs on purpose. The page opens with sixteen references an iOS app holds and what releases each one, then covers reference counting, SwiftUI against UIKit and who owns the state, Swift concurrency up to Swift 6.4, review, TestFlight and the seven-day phased release, Apple’s 2026 deadlines, five documented cases, and where it degrades.

Current version
Swift 6.4 · Xcode 27 · iOS 27, Sep 2026
Memory
Counted, not collected — no pauses, no cycle detection
Release
Every build reviewed; 90% within 24 hours

Who keeps this object alive — sixteen references an iOS app holds

In Swift an object — an instance of a class — lives exactly as long as something holds a strong reference to it. When the last one goes, the object is freed on the spot. Nothing comes along later to find the objects nobody can reach any more, the way a does on Android or in Java. So a screen that should have been freed and was not is never a crash. It is memory that grows, a timer that keeps firing for a screen nobody sees, a network callback that updates a view that is gone. The useful question about any reference is therefore not “is this a leak?”. It is “who holds whom, and what makes them let go?”. Here are sixteen references an iOS app holds every day. Later sections cite the rows by number.

#The referenceWho holds whomWhat happensThe fix
1A local constant — let parser = Parser() inside a functionThe function’s scope holds the objectFreed when the function returnsNothing
2A struct, an enum, a String or an ArrayValues are copied, not shared; a value has no reference count of its ownLives as long as the variable that holds it; copy-on-write keeps the copies cheapNothing — which is why most Swift types are values
3A non-escaping closure — map, filter, sorted(by:)The closure exists only during the callCannot outlive the call, so it cannot form a cycleNothing; [weak self] here is noise
4An escaping closure that self does not store — DispatchQueue.main.async, UIView.animateThe queue or UIKit holds the closure; the closure holds selfKeeps self alive until it has run, then lets go — a delay, not a leakNothing, unless the delay matters
5Task { await self.load() } that finishesThe task holds self until it completesSame as row 4: released when the work endsCancel it when the screen goes away, so the work stops too
6A delegate declared var delegate: Delegate?The child holds its owner while the owner holds the childA cycle: neither is ever freedweak var delegate, with a class-only (AnyObject) protocol
7A closure stored on self that uses self — onTap = { self.close() }self → closure → selfA cycle[weak self] in the capture list
8A child with a strong parent propertyparent → child → parentA cycleweak var parent
9A Combine sink stored in self.cancellables that uses selfself → set → subscription → closure → selfA cycle: the subscription lives as long as self and keeps self alive[weak self], or assign(to: &$property), which does not retain
10A block-based NotificationCenter observer whose block uses selfThe center holds the token, the token holds the block, the block holds selfself lives until the observer is removed[weak self] in the block, and remove the token
11A stored Task that loops forever — for await over notifications — and uses selfself → task → closure → selfA cycle that never ends by itself; cancelling it in deinit cannot work, because deinit never runsCancel it when the screen disappears; in SwiftUI use .task, which is cancelled with the view
12Timer.scheduledTimer(timeInterval: 1, target: self, …)The run loop holds the timer; the timer holds its targetself lives until invalidate() — Apple’s documentation says soinvalidate() when the screen disappears; a block timer with [weak self]
13CADisplayLink(target: self, …) for frame-by-frame drawingThe display link retains its target; the run loop holds the linkAs row 12invalidate(), or a small weak proxy object as the target
14URLSession(configuration:delegate:delegateQueue:)The session keeps a strong reference to its delegateLeaks until the app exits, unless the session is invalidatedfinishTasksAndInvalidate(), or one long-lived session for the whole app
15WKUserContentController.add(self, name:) — a JavaScript message handlerThe web view’s content controller holds the handler stronglyThe screen that owns the web view is never freedremoveScriptMessageHandler(forName:) when the screen disappears, or a weak proxy
16A singleton or a static cache holding a view controller, a view, or a closure that captures oneThe cache lives as long as the appNot a cycle — the object simply never becomes unreachableKeep screens out of long-lived objects; store ids and data, not views
Swift frees an object when nothing owns it any more, and the compiler inserts that counting for you — there is no garbage collector and no pauses. The failure mode is not a forgotten free but a pair of objects that each keep the other alive: the count never reaches zero and neither is ever released. Breaking the cycle means one of the two references giving up ownership, which is what `weak` and `unowned` do, and what `[weak self]` does inside a closure that the object itself is holding.

Reference counting: what Swift does instead of a garbage collector

Automatic Reference Counting, ARC, is simple to state. Every class instance carries a count of the strong references that point at it. The compiler inserts an increment wherever a reference is stored and a decrement wherever one goes away, so you never write them yourself. When the count reaches zero, deinit runs and the memory is returned — immediately, in the same line of execution. That determinism is a real advantage. There are no collection pauses, a released image frees its memory the moment the last screen lets go of it, and a file closed in deinit is closed at a predictable point. The price is the one the table is about: counting cannot see a cycle. Two objects that hold each other keep each other’s count at one forever.

The second half of the model is values. A struct, an enum, a tuple — and String, Array, Dictionary and Set, which are structs in Swift — are copied when they are assigned or passed, so two variables never share one value and there is nothing to count. The standard collections make this cheap with copy-on-write: two copies share one buffer until one of them is changed, and only then is the buffer duplicated. This is why idiomatic Swift starts with a struct and reaches for a class only when identity matters — one shared object that several screens observe, or a resource with a lifetime of its own, such as a connection or a player. Rows 1–3 of the table are easy because most of a Swift program is values.

Kind of referenceOwns the object?When the object goes awayUse it for
strong (the default)YesIt cannot go away while this reference existsAlmost everything
weakNoThe reference becomes nil, so it is always an optional varDelegates, back-references, closures stored on self — rows 6–11
unownedNoThe reference stays non-optional; using it afterwards stops the app with a runtime trapA reference that by construction cannot outlive its target
unowned(unsafe)NoNothing is checked; reading it afterwards is undefined behaviourMeasured hot paths, almost never in app code

Finding a cycle takes seconds once you look. Put a print or a breakpoint in deinit of the screens you care about: if it does not fire when you navigate away, something still holds the screen. Xcode’s Memory Graph Debugger then draws the objects that hold it, arrows and all, and marks the leaks it can prove. Instruments’ Allocations template answers the volume question: open and close the same screen ten times, and the number of live instances of its view controller should return to where it started. A count that climbs by one per visit is rows 6–16 in a single number.

Leaks matter more on iOS than their size suggests, because the system does not page an app’s memory out to disk. When memory runs short it terminates apps — those in the background first — and an app in the foreground that grows past its own limit is terminated too. This is called a jetsam event. To the user it looks like a crash; to the developer it does not arrive as an ordinary crash report, and shows up instead in the memory terminations Xcode’s Organizer and MetricKit count. Images are the usual cause, for the reason the React Native page gives: decoded, an image costs width × height × 4 bytes, so a 12-megapixel photo is 48 MB however small the JPEG was.

SwiftUI or UIKit — and who owns the state

There are two frameworks on the platform, and a working iOS developer reads both. UIKit, there since the first iPhone in 2008, is imperative: you create views, hold references to them and change them when events arrive. SwiftUI, introduced with iOS 13 in 2019, is declarative: you describe what the screen looks like for a given state, and the framework works out what to change — the same model as React, arrived at independently. Most new screens are written in SwiftUI. Most existing apps are UIKit with SwiftUI growing inside them, and the two interoperate in both directions: UIHostingController puts SwiftUI inside UIKit, UIViewRepresentable puts a UIKit view inside SwiftUI.

One fact explains almost every SwiftUI surprise: a SwiftUI view is a struct. It is a description, cheap to make and thrown away after use, and SwiftUI creates new values of it whenever a parent’s body runs again — dozens of times in a busy screen. So state cannot live in the struct. Property wrappers such as @State tell SwiftUI to keep the storage outside the view, attached to the view’s identity — its place in the hierarchy — and to hand it back to every new value of the struct. When identity changes, the state goes with it: a view moved between the branches of an if, or given a new .id, starts again from its initial value. That is the same rule as keys in React, and the same class of bug.

A SwiftUI view is a struct: a description that SwiftUI creates again every time the parent's body runs, and throws away after use. So state cannot live in the struct. Property wrappers such as `@State` tell SwiftUI to keep the storage outside, attached to the view's identity, and to hand the same storage to every new value. An object created in `@ObservedObject` is owned by no one, so each new value of the view creates its own and the screen's state resets — own it with `@StateObject`, or with `@State` for an `@Observable` class. Built with Xcode 27, `@State` is a macro that creates its default value only the first time.
WrapperOwns the data?SinceUse it for
@StateYes — kept by SwiftUI for as long as the view’s identity lastsiOS 13A value local to the view; since iOS 17 also an @Observable model the view creates
@BindingNo — reads and writes a parent’s stateiOS 13A child that edits a value it does not own
@Observable classThe class owns its data; a view re-renders only for properties its body readiOS 17Models shared between screens
@BindableNoiOS 17Bindings into an @Observable model passed in from outside
@EnvironmentNo — a value placed higher up the hierarchyiOS 13Dependencies and settings many views read
@StateObjectYes — created once per identityiOS 14An ObservableObject the view owns, in code before Observation
@ObservedObjectNo — it only watches an object someone else keepsiOS 13An ObservableObject passed in; never one created right here

What your minimum iOS version gives you

SwiftUI ships inside the operating system, not inside your app, so the oldest iOS you support decides which SwiftUI you have. A feature added in iOS 17 is simply not there on a phone running iOS 16, and code that uses it needs an if #available branch with a fallback. That makes the deployment target a product decision with a price on both sides: too low, and the team writes fallbacks for features the framework already has; too high, and a share of users cannot install the next update. Adoption of new iOS versions is fast, so most teams support the current version and one or two before it.

Minimum iOSYearWhat SwiftUI gained
iOS 132019SwiftUI itself: views, @State, @Binding, @ObservedObject
iOS 142020@StateObject, the App protocol, lazy stacks and grids
iOS 152021.task — work tied to a view’s lifetime — AsyncImage, .refreshable
iOS 162022NavigationStack, Swift Charts, the Layout protocol
iOS 172023Observation (@Observable, @Bindable), SwiftData
iOS 182024Custom containers, zoom transitions, mesh gradients, the Tab type
iOS 262025Liquid Glass, a SwiftUI WebView, rich text in TextEditor
iOS 272026Drag-to-reorder in any container, swipe actions outside List, a new document API

SwiftUI

  • Far less code for the same screen, with live previews while you type.
  • One framework for iPhone, iPad, Mac, Watch, TV and Vision Pro.
  • State has one owner and the screen follows it — no view to keep in sync by hand.
  • Your minimum iOS version decides which features exist; edge cases still drop into UIKit.

UIKit

  • Eighteen years of answers, libraries and behaviour nobody has to guess at.
  • Complete control over layout, transitions, text input and every edge case.
  • What most existing apps are written in, so you will read it regardless.
  • More code, more state kept in sync by hand, and the delegate and target patterns behind rows 6 and 12.

One more difference between the two showed up in 2025. Both are drawn by the operating system, so when iOS 26 redesigned its controls in Liquid Glass, every SwiftUI and UIKit app rebuilt with the iOS 26 SDK took on the new look — tab bars, navigation bars, switches, alerts — without a line of code. Apple let apps opt out for one year with the UIDesignRequiresCompatibility key, and the key is ignored once an app is built with the iOS 27 SDK. The Flutter page draws what that redesign did to SwiftUI, React Native and Flutter apps side by side; for a native app the work is the custom-drawn views, which change only when you change them.

Swift concurrency: who may touch this state

One rule on this platform never changes: the user interface is touched only from the main thread. The main thread also has a supervisor. A watchdog terminates an app that does not draw its first frame in time or stops answering, and the crash report carries the code 0x8badf00d — “ate bad food”. Apple’s own documentation shows the classic cause: Data(contentsOf:) with an https URL looks like reading a file and quietly makes a synchronous network request on whatever thread called it. On a fast office network it takes milliseconds. On a train it takes long enough for the watchdog.

Swift’s answer, added in Swift 5.5 in 2021, has three parts. async and await let a function wait for a network response or a file without blocking its thread: at each await the function steps aside and the thread does other work. An actor is a type that protects its own state by letting one task in at a time, so two tasks cannot change the same dictionary at once. @MainActor is the actor that stands for the main thread, so marking a view model with it lets the compiler, not code review, check that its methods run there. The third part is Sendable: a type that may safely cross from one actor to another, either because it is a value or because it guards its own state.

Swift checks concurrency by isolation: every piece of mutable state belongs to one domain, and only code in that domain may touch it. The main actor stands for the main thread, and since Swift 6.2 a module can make it the default — Xcode 26 does so for new apps — so app code is single-threaded unless it leaves on purpose. An actor such as an image cache guards its own state by letting one task in at a time. A function marked `@concurrent` runs on the background pool. Every crossing is an `await`, and only `Sendable` values may cross. In the Swift 6 language mode, code that could let two threads touch the same state without coordination does not compile.

Swift 6, in September 2024, made the checks errors rather than warnings in its new language mode: code that could let two threads touch the same mutable state without coordination stops compiling. A data race became a compile error, the same move Rust made with ownership. The first migrations were hard, because most app code had never said which actor it belonged to, and the compiler flagged it everywhere. Swift 6.2, in September 2025, changed the defaults instead of the rules. A module can now make everything @MainActor unless marked otherwise; an async function that is not tied to an actor runs on its caller’s actor instead of jumping to a background thread; and @concurrent marks the functions that should leave. Xcode 26 turned on both settings for new app projects. The modern starting point is therefore single-threaded: code runs on the main actor, and work leaves it on purpose.

VersionReleasedWhat changed for an app developer
Swift 5.0Mar 2019A stable binary interface: the Swift runtime ships in iOS 12.2 and later, so apps stop bundling it
Swift 5.5Sep 2021async/await, actors, structured concurrency, Sendable
Swift 5.9Sep 2023Macros; the Observation framework behind @Observable
Swift 6.0Sep 2024The Swift 6 language mode: data races are compile errors; typed throws
Swift 6.2Sep 2025Main actor by default (opt-in, on in new Xcode 26 projects), @concurrent; InlineArray, Span
Swift 6.3Mar 2026The Swift SDK for Android released; @c for C interop; module selectors (Module::name)
Swift 6.4Sep 2026await inside defer; task cancellation shields; Swift Build as the package manager’s default; ships with Xcode 27

Shipping: review, TestFlight and a seven-day rollout

The part that surprises teams arriving from the web is that shipping is not a deploy. Every binary goes through App Review before anyone outside the team can install it. Apple says that on average 90% of submissions are reviewed in less than 24 hours, and that over 40% of the problems that stop a submission fall under guideline 2.1, App Completeness — crashes, placeholder content, a demo account the reviewer could not log in with. For a critical bug fix there is an expedited review, which Apple grants case by case. And since June 2020, a bug fix for an app already on the store is not held back over a guideline violation unless the issue is legal; the cases section says how that rule came about.

There is no way around the binary. Swift is compiled ahead of time to machine code, and the Developer Program License Agreement allows downloaded code only if it is interpreted and does not change the app’s primary purpose — which is what lets React Native ship updates, and the React Native page quotes the clause. What a native app can change without a release is what it was built to read: server-driven content, remote configuration, a that switches on code already in the binary. Anything else waits for the next build, which is why the order of the steps below matters.

Every iOS build reaches users through the same four steps. TestFlight puts it on real devices first — up to 100 people on the team as soon as the build is processed, up to 10,000 external testers after Beta App Review — and each build stays installable for 90 days. App Review then looks at the release; Apple says 90% of submissions are reviewed in under 24 hours. A phased release spreads the update over seven days on a fixed schedule, and the chart is to scale: on day three the update has reached only 5% of users. It throttles automatic updates only, it can be paused for 30 days in total, and it cannot be undone — people who have the update keep it, so the way back is a new build.

TestFlight is the step before the store. Up to 100 internal testers with access to the team’s App Store Connect account get a build as soon as it has finished processing. Up to 10,000 external testers can join by invitation or a public link; the first build for them goes through Beta App Review. Each build stays installable for 90 days. Then comes the release itself, and the most useful switch in App Store Connect: a phased release. The update reaches 1% of users on day one, then 2%, 5%, 10%, 20%, 50% and everyone on day seven. You can pause it for up to 30 days in total, or release to everyone at once.

Apple’s calendar — the requirements in force around this page’s date

SinceWhat it requiresWhat it means in the code
30 Jun 2022An app that lets people create an account lets them delete it inside the app (guideline 5.1.1(v))A real deletion path — deactivation is not enough — and Sign in with Apple tokens revoked
1 May 2024Approved reasons for “required reason” APIs, including those called by third-party SDKsA privacy manifest declaring why the app reads user defaults, file timestamps, boot time, free disk space or the active keyboards
31 Jan 2026Answers to the new age-rating questions for every appWithout them, updates stop at submission
28 Apr 2026Uploads built with Xcode 26 and the iOS 26 SDKLiquid Glass on every system control; the one-year opt-out key still works
9 Sep 2026Uploads target iOS 13 or laterA deployment target below iOS 13 is rejected at upload
Building with Xcode 27 (released 14 Sep 2026)The scene-based (UIScene) lifecycle, or the app does not launch; UIDesignRequiresCompatibility ignoredAn app delegate that owns the window has to move to a scene delegate before the iOS 27 SDK becomes mandatory — Apple has raised the minimum SDK every April
2 Dec 2026 (planned)CocoaPods trunk becomes read-onlyExisting builds keep working, but no new versions of any pod arrive; move dependencies to Swift Package Manager

Which iOS codebase are you looking at

What you see in the codeWhat it tells you
An AppDelegate with a window property and no SceneDelegateThe pre-iOS 13 lifecycle; built with the iOS 27 SDK, the app does not launch
ObservableObject, @Published and @StateObject everywhereWritten before Observation (iOS 17); works, but every change re-renders every view that observes the object
NavigationViewDeprecated since iOS 16; NavigationStack replaces it
Completion handlers and DispatchQueue.main.async in every network callConcurrency from before 2021, which the compiler’s data-race checks cannot see into
SWIFT_VERSION = 5 with minimal concurrency checkingNobody has asked the compiler about data races yet; turn checking up one module at a time
A PodfileCocoaPods, whose central repository stops accepting new versions on 2 Dec 2026
UIDesignRequiresCompatibility set to YES in Info.plistThe team postponed Liquid Glass; with the iOS 27 SDK the key is ignored
Timer.scheduledTimer(… target: self …) with no invalidate() nearbyRow 12 of the table, waiting to be found

Five cases, and what each one was missing

A native iOS team inherits two things it does not control: the code of every SDK it ships, and the rules of the company that runs the store. All five cases are about one or the other, and each is documented by the people involved — a GitHub issue, a company statement, Apple’s own announcements.

CaseWhat happenedWhat was missing
Facebook SDK, 6 May and 10 Jul 2020A change on Facebook’s servers crashed Spotify, Pinterest, TikTok, Venmo and others at launch, twiceA way to switch a third-party SDK off, and parsing that does not trust the server
HEY, June 2020A bug-fix update rejected because the app sold its subscription only on the web; approved on 22 June after adding a free 14-day addressThe business model checked against guideline 3.1 before the code was written
The iOS 14 paste banner, June 2020The first beta showed a banner whenever an app read the clipboard; TikTok read it every few keystrokes, LinkedIn on every oneAn inventory of what the app and its SDKs read — required since May 2024 as a privacy manifest
App Tracking Transparency, Apr 2021iOS 14.5 required a prompt before tracking across other companies’ apps; Meta estimated the cost at about $10 billion of 2022 revenueA product that did not depend on a default the operating system could change
Account deletion, 30 Jun 2022Apps with sign-up had to offer deletion inside the app; the deadline had already been extended onceA deletion path designed with sign-up, not after review found it missing

The first case shows what an SDK at launch can do. On 6 May 2020, from 14:44 Pacific time, apps that embedded Facebook’s iOS SDK began to crash the moment they opened — Spotify, Pinterest, TikTok, Venmo, Tinder and more, including for users who never signed in with Facebook. The issue filed on GitHub that afternoon pointed at one line: the SDK downloaded a configuration from Facebook’s servers at start-up and read a value in it as a dictionary, and the server had started sending something else. Facebook fixed it on the server within hours. On 10 July 2020 it happened again, through the same component. The affected apps had done nothing wrong in their own code. What they lacked was distance: an SDK initialised on the launch path, before the first screen, with no switch to turn it off remotely, and no tolerance for a server that answers in an unexpected shape.

The third case is the operating system making visible what code had always done. In June 2020 the first iOS 14 beta started showing a small banner whenever an app read the clipboard. Within days users posted recordings: TikTok read it every few keystrokes, LinkedIn on every keystroke. TikTok said it was an anti-spam feature and removed it; LinkedIn called it a bug. The banner did not care whose code read the clipboard — the app’s own or an SDK inside it — and that is the point: a team answers for everything its binary does. Apple turned that into a requirement in May 2024: an upload must declare an approved reason for each “required reason” API it calls, including those called by third-party SDKs. The fourth case is the same pattern at larger scale. With iOS 14.5, on 26 April 2021, an app had to ask before tracking a person across other companies’ apps, and the system shows that prompt once. In February 2022 Meta told investors the change would cost it on the order of $10 billion that year — an estimate, its chief financial officer said, and the only number of its size anyone published. A default the platform owns can be changed by the platform.

Where it degrades

Native iOS is generous where a team is disciplined and quiet where it is not. The compiler catches more each year — data races since Swift 6 — but it still cannot tell you that a screen was never freed, that an SDK reads the clipboard, or that the next SDK requirement is six months away. Almost every failure is one of these:

  • Stored closures and delegates that capture their owner strongly, leaking silently and never crashing (rows 6–9).
  • Timers, display links, sessions and web-view handlers nobody invalidates (rows 12–15).
  • A task that loops forever and keeps its screen with it (row 11).
  • unowned where weak was meant, which turns a leak into a crash on a slow network.
  • A model created inside @ObservedObject, which resets every time the parent updates.
  • Synchronous work on the main thread — a file, a decode, a hidden network call — until the watchdog ends it.
  • A continuation that is not resumed on every path, leaving a screen loading forever.
  • Third-party SDKs started at launch with no way to switch them off and no list of what they read.
  • Permission prompts at launch instead of at the moment a feature needs them — the system asks once.
  • Apple’s requirements — deletion, privacy manifests, the scene lifecycle, the minimum SDK — discovered on the day the release was due.

The honest limit is the platform itself. A native iOS app is the best version of an app on an iPhone — the newest APIs on day one, the platform’s own controls and their redesigns for free, deterministic memory, a compiler that rules out data races. It is also one platform. A product that needs Android too pays for a second native team, or for a cross-platform layer, or — as Shopify concluded in September 2026, a story the React Native page tells — for coding agents that translate between the two; none of those is free. And every release still passes through one company’s review and one company’s calendar. A team that plans for both treats them as part of the schedule rather than as weather.

The practical check takes ten minutes. Read the deployment target and the Xcode version, and find them in the calendar table. Search the code for var delegate: without weak, Timer.scheduledTimer(, addObserver(forName:, .sink { without a capture list, URLSession(configuration: with a delegate and add(self, name:. Open and close the three busiest screens ten times each with the Memory Graph Debugger open. Check that Info.plist declares a scene manifest. Look at the Swift language mode and the concurrency-checking level of each target. And confirm that the next release is set to use a phased release.

When to use it

Use it when

  • Products where the iPhone experience is the product — performance, polish and the platform’s conventions.
  • Anything built on a capability Apple ships first — widgets, Live Activities, on-device models, new hardware.
  • Apps where deterministic memory, no collection pauses and compile-time data-race checks genuinely matter.
  • Teams that can support one platform properly rather than two approximately.

Avoid it when

  • Products that need both platforms with one team and one budget — that is what the cross-platform tools are for.
  • Anything that must change its native code the same day; every binary goes through review, and there is no rollback.
  • Business models that conflict with the store’s payment rules — resolve that before writing code.
  • A team with no Mac hardware and no capacity to follow Apple’s yearly SDK calendar; there is no way around Xcode.

Sources and method

What this page stands on

  • 18 sources
  • 2 documented failures
  • Checked October 13, 2026

Researched with Claude. Finding and cross-checking the primary sources was done with Claude (Anthropic). Selection, writing and fact-checking: the Anatomy of IT Product editors.

Found this useful?

Share it with someone who is working on the same problem.