iOS Performance Optimization
Target 60fps UI rendering, <1s screen loads, <100ms tap-to-response interactions, >99.5% crash-free sessions. Profile on real devices (iPhone 12/13 minimum) since simulators don't reflect actual CPU, GPU, or memory constraints. Performance regressions compound - a 200ms delay feels instant, but 10 such delays create a 2-second lag that users notice.
Overview
This guide covers iOS performance optimization strategies including memory management with ARC (Automatic Reference Counting), weak and unowned references, Instruments profiling tools, SwiftUI performance optimization, image and network optimization, and battery efficiency.
Core Principles
- Measure First: Use Instruments to identify actual bottlenecks before optimizing
- Memory Efficiency: Prevent retain cycles with weak/unowned, minimize allocations
- ARC Awareness: Understand reference counting and object lifecycle
- Instruments Profiling: Master Time Profiler, Allocations, Leaks instruments
- SwiftUI Optimization: Minimize view updates, use identity, lazy loading
- Image Optimization: Downsampling, caching, appropriate formats
- Network Efficiency: Reduce payload size, caching, background URLSession
- Battery Conservation: Defer work, batch operations, avoid polling
- Profile Continuously: Integrate profiling into development workflow
- Real Device Testing: Simulators don't reflect real-world performance
Performance Optimization Flow
Memory Management with ARC
Understanding ARC
Swift uses Automatic Reference Counting (ARC) to manage memory by tracking how many references point to each object. Every time you assign an object to a variable, property, or constant, ARC increments its reference count. When a reference goes out of scope, ARC decrements the count. When the count reaches zero, ARC immediately deallocates the object. Unlike garbage collection (which periodically scans memory and pauses execution to clean up), ARC inserts retain/release operations at compile time, making deallocation deterministic and predictable. However, ARC cannot detect retain cycles - if object A holds a strong reference to object B, and B holds a strong reference to A, both reference counts remain >0 forever and neither is deallocated.
// GOOD: Understanding strong references
class Payment {
let id: String
let amount: Decimal
init(id: String, amount: Decimal) {
self.id = id
self.amount = amount
print("Payment \(id) allocated")
}
deinit {
print("Payment \(id) deallocated")
}
}
func createPayment() {
let payment = Payment(id: "123", amount: 100.00) // Reference count: 1
// payment used here
} // Reference count drops to 0, payment deallocated
Retain Cycles and Memory Leaks
** BAD: Strong reference cycle**
// BAD: PaymentManager and Payment hold strong references to each other
class PaymentManager {
var currentPayment: Payment?
func processPayment(_ payment: Payment) {
currentPayment = payment
payment.manager = self // Creates retain cycle!
}
}
class Payment {
var manager: PaymentManager? // Strong reference
let id: String
init(id: String) {
self.id = id
}
deinit {
print("Payment deallocated") // Never called!
}
}
** GOOD: Break retain cycle with weak reference**
// GOOD: Use weak reference to prevent cycle
class PaymentManager {
var currentPayment: Payment?
func processPayment(_ payment: Payment) {
currentPayment = payment
payment.manager = self // No cycle with weak
}
}
class Payment {
weak var manager: PaymentManager? // Weak reference breaks cycle
let id: String
init(id: String) {
self.id = id
}
deinit {
print("Payment deallocated") // Now called correctly
}
}
Weak vs Unowned References
// GOOD: Use weak when reference can become nil
class PaymentViewController: UIViewController {
weak var delegate: PaymentDelegate? // Can be nil
func completePayment() {
delegate?.paymentCompleted() // Safe optional chaining
}
}
// GOOD: Use unowned when reference should never be nil
class PaymentReceipt {
unowned let payment: Payment // Always exists with receipt
init(payment: Payment) {
self.payment = payment
}
func printReceipt() {
print("Receipt for payment: \(payment.id)") // No optional
}
}
// BAD: Using unowned when reference can be nil
class PaymentView {
unowned let viewModel: PaymentViewModel // Crashes if nil!
}
Closure Capture Lists
** BAD: Strong reference cycle with closure**
// BAD: ViewModel captured strongly in closure
class PaymentViewModel {
var onComplete: (() -> Void)?
func processPayment() {
paymentService.process { [self] result in
// Strong capture of self - retain cycle!
self.handleResult(result)
self.onComplete?()
}
}
}
** GOOD: Use weak self in closures**
// GOOD: Weak capture prevents retain cycle
class PaymentViewModel {
var onComplete: (() -> Void)?
func processPayment() {
paymentService.process { [weak self] result in
guard let self else { return } // Object deallocated, exit early
self.handleResult(result)
self.onComplete?()
}
}
}
// GOOD: Use unowned when closure lifespan < object lifespan
class PaymentProcessor {
func process(completion: @escaping (Result<Payment, Error>) -> Void) {
asyncOperation { [unowned self] in
// Safe because processor outlives async operation
let result = self.validate()
completion(result)
}
}
}
Common Memory Leak Patterns
** BAD: Timer retain cycle**
// BAD: Timer retains target strongly
class PaymentPollingService {
var timer: Timer?
func startPolling() {
timer = Timer.scheduledTimer(
timeInterval: 5.0,
target: self, // Strong reference!
selector: #selector(poll),
userInfo: nil,
repeats: true
)
}
@objc func poll() {
// Polling logic
}
deinit {
timer?.invalidate() // Never called due to retain cycle!
}
}
** GOOD: Use weak self with timer**
// GOOD: Break timer retain cycle
class PaymentPollingService {
var timer: Timer?
func startPolling() {
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
guard let self else { return }
self.poll()
}
}
func poll() {
// Polling logic
}
deinit {
timer?.invalidate() // Now called correctly
print("Polling service deallocated")
}
}
Detecting Memory Leaks
Enable Debug Memory Graph:
- Run app in Xcode
- Trigger memory leak scenario
- Click Debug Memory Graph button (⊞ icon in debug bar)
- Look for purple exclamation marks (retain cycles)
- Inspect object references
Instruments Leaks Tool:
// Profile with Leaks instrument to detect:
// - Abandoned memory
// - Retain cycles
// - Reference cycles with closures
Instruments Profiling
Time Profiler
Identify CPU-intensive operations:
- Launch Instruments: Xcode → Product → Profile (⌘I)
- Select Time Profiler
- Record while performing operations
- Stop recording and analyze
Look for:
- Functions taking >100ms
- Main thread blocking
- Recursive calls
- Inefficient algorithms
Example: Detecting slow JSON parsing
// BAD: FOUND BY TIME PROFILER: Synchronous JSON parsing on main thread (350ms)
struct PaymentListView: View {
@State private var payments: [Payment] = []
var body: some View {
List(payments) { payment in
PaymentCard(payment: payment)
}
.onAppear {
// TIME PROFILER SHOWS: 350ms on main thread!
if let data = loadPaymentsFromFile() {
payments = try! JSONDecoder().decode([Payment].self, from: data)
}
}
}
}
// GOOD: FIX: Move to background thread
struct PaymentListView: View {
@StateObject private var viewModel = PaymentListViewModel()
var body: some View {
List(viewModel.payments) { payment in
PaymentCard(payment: payment)
}
.task {
await viewModel.loadPayments() // Async, off main thread
}
}
}
@MainActor
class PaymentListViewModel: ObservableObject {
@Published var payments: [Payment] = []
func loadPayments() async {
do {
// Decoding on background thread
let data = await loadPaymentsFromFile()
payments = try JSONDecoder().decode([Payment].self, from: data)
} catch {
print("Error loading payments: \(error)")
}
}
}
Allocations Instrument
Track memory allocations and growth:
- Launch Allocations instrument
- Filter by allocation type (All Heap Allocations)
- Look for growth over time
- Mark generations to track allocation patterns
Look for:
- Persistent growth (memory leak)
- Large allocations (>10 MB)
- Allocation spikes during operations
- Abandoned memory
Example: Detecting image allocation leak
// BAD: ALLOCATIONS SHOWS: Image memory never released (50MB+)
class PaymentDetailViewController: UIViewController {
let imageView = UIImageView()
func loadReceiptImage(url: URL) {
URLSession.shared.dataTask(with: url) { data, _, _ in
if let data = data {
// Full-size image loaded into memory!
let image = UIImage(data: data)
DispatchQueue.main.async {
self.imageView.image = image
}
}
}.resume()
}
}
// GOOD: FIX: Downsample images
func loadReceiptImage(url: URL) {
URLSession.shared.dataTask(with: url) { data, _, _ in
if let data = data {
// Downsample to display size
let image = downsample(imageData: data, to: CGSize(width: 400, height: 600))
DispatchQueue.main.async {
self.imageView.image = image
}
}
}.resume()
}
func downsample(imageData: Data, to size: CGSize, scale: CGFloat = UIScreen.main.scale) -> UIImage? {
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height) * scale
]
guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil),
let image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else {
return nil
}
return UIImage(cgImage: image)
}
Network Instrument
Optimize network requests:
- Launch Network instrument
- Record network operations
- Analyze request timing, size, failures
Look for:
- Large responses (>500 KB)
- Slow requests (>2s)
- Redundant API calls
- Missing caching headers
// GOOD: Optimize network with caching and compression
func configureURLSession() -> URLSession {
let config = URLSessionConfiguration.default
// Enable caching
let cache = URLCache(
memoryCapacity: 10 * 1024 * 1024, // 10 MB memory
diskCapacity: 50 * 1024 * 1024, // 50 MB disk
diskPath: "payment_cache"
)
config.urlCache = cache
config.requestCachePolicy = .returnCacheDataElseLoad
// Enable compression
config.httpShouldSetCookies = false
config.httpAdditionalHeaders = [
"Accept-Encoding": "gzip, deflate"
]
// Timeout configuration
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 300
return URLSession(configuration: config)
}
Leaks Instrument
Find memory leaks:
- Launch Leaks instrument
- Perform actions that may leak
- Force leak detection (Leaks checks every 10s)
- Inspect leaked objects and reference graphs
// Leaks instrument detects:
// - Abandoned memory (no references but not deallocated)
// - Retain cycles
// - Leaked closures
SwiftUI Performance
Minimize View Updates
** BAD: Entire view updates on every change**
// BAD: All payments re-render on any state change
struct PaymentListView: View {
@State private var payments: [Payment]
@State private var searchText: String = ""
@State private var isLoading: Bool = false
var body: some View {
// Entire body re-evaluates on any @State change
VStack {
TextField("Search", text: $searchText) // searchText change triggers full re-render
List(payments.filter { $0.matches(searchText) }) { payment in
PaymentRow(payment: payment) // All rows re-created!
}
}
}
}
** GOOD: Isolate state changes**
// GOOD: Separate concerns, minimal updates
struct PaymentListView: View {
@StateObject private var viewModel = PaymentListViewModel()
var body: some View {
VStack {
SearchBar(text: $viewModel.searchText) // Isolated search state
PaymentListContent(payments: viewModel.filteredPayments)
}
}
}
struct PaymentListContent: View {
let payments: [Payment] // Stable input
var body: some View {
List(payments, id: \.id) { payment in
PaymentRow(payment: payment) // Only updates when payment changes
}
}
}
@MainActor
class PaymentListViewModel: ObservableObject {
@Published var searchText: String = ""
@Published private(set) var payments: [Payment] = []
var filteredPayments: [Payment] {
if searchText.isEmpty {
return payments
}
return payments.filter { $0.matches(searchText) }
}
}
Use Equatable and Identifiable
// GOOD: Equatable enables diffing
struct Payment: Identifiable, Equatable {
let id: String
let recipientName: String
let amount: Decimal
let status: PaymentStatus
// Equatable enables SwiftUI to skip unchanged views
static func == (lhs: Payment, rhs: Payment) -> Bool {
lhs.id == rhs.id &&
lhs.status == rhs.status &&
lhs.amount == rhs.amount
}
}
struct PaymentRow: View, Equatable {
let payment: Payment
var body: some View {
HStack {
Text(payment.recipientName)
Spacer()
Text(payment.amount.formatted(.currency(code: "USD")))
}
}
// Equatable: only re-render if payment changed
static func == (lhs: PaymentRow, rhs: PaymentRow) -> Bool {
lhs.payment == rhs.payment
}
}
Lazy Loading
// GOOD: Use LazyVStack/LazyHStack for large lists
struct TransactionHistoryView: View {
let transactions: [Transaction]
var body: some View {
ScrollView {
// LazyVStack only creates visible views
LazyVStack(spacing: 12) {
ForEach(transactions) { transaction in
TransactionRow(transaction: transaction)
}
}
}
}
}
// BAD: VStack loads all views immediately
struct TransactionHistoryView: View {
let transactions: [Transaction]
var body: some View {
ScrollView {
// All 1000 rows created at once - slow!
VStack(spacing: 12) {
ForEach(transactions) { transaction in
TransactionRow(transaction: transaction)
}
}
}
}
}
Avoid Heavy Computations in Body
// BAD: Expensive calculation on every render
struct PaymentSummaryView: View {
let payments: [Payment]
var body: some View {
// Recalculated every time body is evaluated!
let totalAmount = payments
.filter { $0.status == .completed }
.reduce(Decimal.zero) { $0 + $1.amount }
Text("Total: \(totalAmount.formatted(.currency(code: "USD")))")
}
}
// GOOD: Compute once in ViewModel
struct PaymentSummaryView: View {
let totalAmount: Decimal // Pre-computed
var body: some View {
Text("Total: \(totalAmount.formatted(.currency(code: "USD")))")
}
}
@MainActor
class PaymentSummaryViewModel: ObservableObject {
@Published private(set) var totalAmount: Decimal = 0
func updatePayments(_ payments: [Payment]) {
// Compute once when data changes
totalAmount = payments
.filter { $0.status == .completed }
.reduce(Decimal.zero) { $0 + $1.amount }
}
}
Image Optimization
Downsampling Images
// GOOD: Downsample images to reduce memory
func loadImage(from url: URL, targetSize: CGSize) -> UIImage? {
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil) else {
return nil
}
let maxDimension = max(targetSize.width, targetSize.height) * UIScreen.main.scale
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension
]
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else {
return nil
}
return UIImage(cgImage: cgImage)
}
// Usage
let profileImage = loadImage(
from: imageURL,
targetSize: CGSize(width: 100, height: 100) // UIImageView size
)
Image Caching
// GOOD: Use NSCache for automatic memory management
class ImageCache {
static let shared = ImageCache()
private let cache = NSCache<NSString, UIImage>()
private init() {
// Configure cache limits
cache.countLimit = 100 // Max 100 images
cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
}
func getImage(forKey key: String) -> UIImage? {
return cache.object(forKey: key as NSString)
}
func setImage(_ image: UIImage, forKey key: String) {
let cost = image.pngData()?.count ?? 0
cache.setObject(image, forKey: key as NSString, cost: cost)
}
func clear() {
cache.removeAllObjects()
}
}
Async Image Loading
// GOOD: Use AsyncImage with placeholder
struct UserAvatarView: View {
let imageURL: URL
var body: some View {
AsyncImage(url: imageURL) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
Image(systemName: "person.circle.fill")
.foregroundColor(.gray)
@unknown default:
EmptyView()
}
}
.frame(width: 50, height: 50)
.clipShape(Circle())
}
}
Network Optimization
Reduce Payload Size
// GOOD: Request only needed fields
struct PaymentListRequest: Encodable {
let limit: Int
let offset: Int
let fields: [String] // Only request needed fields
init(limit: Int = 20, offset: Int = 0) {
self.limit = limit
self.offset = offset
self.fields = ["id", "amount", "recipientName", "status", "createdAt"]
}
}
// API returns minimal data:
// Before: 250 KB for 20 payments
// After: 45 KB for 20 payments (82% reduction)
Background URLSession
// GOOD: Use background URLSession for downloads
class ReceiptDownloadManager {
static let shared = ReceiptDownloadManager()
private lazy var backgroundSession: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "com.bank.receipts")
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
func downloadReceipt(url: URL) {
let task = backgroundSession.downloadTask(with: url)
task.resume()
// Download continues even if app backgrounds
}
}
extension ReceiptDownloadManager: URLSessionDownloadDelegate {
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
// Handle completed download
}
}
Request Deduplication
// GOOD: Deduplicate simultaneous requests
class PaymentService {
private var inFlightRequests: [String: Task<Payment, Error>] = [:]
func getPayment(id: String) async throws -> Payment {
// If request already in flight, return existing task
if let existingTask = inFlightRequests[id] {
return try await existingTask.value
}
// Create new request
let task = Task { () -> Payment in
defer { inFlightRequests[id] = nil }
return try await networkManager.request(.getPayment(id: id))
}
inFlightRequests[id] = task
return try await task.value
}
}
Battery Optimization
Batch Operations
// GOOD: Batch location updates
class LocationTracker: NSObject, CLLocationManagerDelegate {
let locationManager = CLLocationManager()
func startTracking() {
locationManager.delegate = self
// Defer location updates for battery efficiency
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
locationManager.distanceFilter = 100 // Only update every 100m
locationManager.allowsBackgroundLocationUpdates = false
locationManager.startUpdatingLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
// Process batched locations
processBatch(locations)
}
}
Avoid Polling
// BAD: Polling every 5 seconds
class PaymentStatusChecker {
var timer: Timer?
func startPolling() {
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
self.checkPaymentStatus() // Battery drain!
}
}
}
// GOOD: Use push notifications or WebSockets
class PaymentStatusMonitor {
func monitorPayment(id: String) {
// Register for remote notifications
setupPushNotifications()
// Or use WebSocket for real-time updates
connectWebSocket()
}
func didReceivePushNotification(_ notification: [AnyHashable: Any]) {
// Update only when server pushes update
handlePaymentStatusUpdate(notification)
}
}
Efficient Background Tasks
// GOOD: Use background tasks efficiently
import BackgroundTasks
class BackgroundSyncManager {
func scheduleSync() {
let request = BGAppRefreshTaskRequest(identifier: "com.bank.sync")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Failed to schedule sync: \(error)")
}
}
func handleBackgroundSync(task: BGAppRefreshTask) {
// Set expiration handler
task.expirationHandler = {
task.setTaskCompleted(success: false)
}
// Perform sync
Task {
do {
try await syncPayments()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
// Schedule next sync
scheduleSync()
}
}
}
Performance Examples
Optimized Payment List
// GOOD: Fully optimized payment list
@MainActor
class PaymentListViewModel: ObservableObject {
@Published private(set) var payments: [Payment] = []
@Published var isLoading: Bool = false
private let paymentService: PaymentService
private var currentPage = 0
private let pageSize = 20
init(paymentService: PaymentService = .shared) {
self.paymentService = paymentService
}
func loadPayments() async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let newPayments = try await paymentService.getPayments(
page: currentPage,
pageSize: pageSize
)
payments.append(contentsOf: newPayments)
currentPage += 1
} catch {
print("Failed to load payments: \(error)")
}
}
}
struct PaymentListView: View {
@StateObject private var viewModel = PaymentListViewModel()
var body: some View {
List {
ForEach(viewModel.payments) { payment in
PaymentRow(payment: payment)
.onAppear {
// Prefetch when near end
if payment == viewModel.payments.last {
Task {
await viewModel.loadPayments()
}
}
}
}
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
}
}
.task {
await viewModel.loadPayments()
}
}
}
struct PaymentRow: View, Equatable {
let payment: Payment
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(payment.recipientName)
.font(.headline)
Text(payment.createdAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(payment.amount.formatted(.currency(code: payment.currency)))
.font(.title3)
.fontWeight(.semibold)
StatusBadge(status: payment.status)
}
}
.padding(.vertical, 8)
}
static func == (lhs: PaymentRow, rhs: PaymentRow) -> Bool {
lhs.payment.id == rhs.payment.id &&
lhs.payment.status == rhs.payment.status
}
}
Performance Checklist
Before Release
- Profile with Instruments (Time Profiler, Allocations, Leaks)
- Test on real devices (not just simulator)
- Test on older devices (iPhone 12 minimum)
- Verify 60fps during animations and scrolling
- Check memory usage (<100 MB for typical screens)
- Validate network efficiency (<500 KB per screen)
- Test with slow network (Network Link Conditioner)
- Verify battery impact (no excessive background activity)
- Check app launch time (<2s cold start)
- Profile critical user flows (<1s per interaction)
Memory Management
- No retain cycles (verified with Memory Graph / Leaks)
- Weak/unowned references used correctly
- Closures use capture lists [weak self] where appropriate
- Timers invalidated in deinit
- Observers removed in deinit
- Images downsampled to display size
- Image caching implemented
SwiftUI Performance
- Large lists use LazyVStack/List
- Heavy computations moved to ViewModel
- Equatable conformance for views and models
- State isolated to minimize updates
- No heavy work in view body
Further Reading
iOS Framework Guidelines
- iOS Overview - Build configuration and optimizations
- iOS UI Development - SwiftUI rendering performance
- iOS Data & Networking - Network and data layer performance
- iOS Security - Performance impact of security measures
- iOS Testing - Performance testing and benchmarking
- iOS Architecture - Architecture for performance
Performance Guidelines
- Performance Overview - Performance strategy
- Performance Optimization - Cross-platform optimization
- Performance Testing - Load and performance testing
Mobile Guidelines
- Mobile Overview - Mobile performance patterns
- Mobile Performance - Cross-platform mobile optimization
Language Performance
- Swift Performance - Swift-specific optimizations
External Resources
- WWDC: iOS Memory Deep Dive
- Instruments Help
- SwiftUI Performance
- Image and Graphics Best Practices
- Energy Efficiency Guide
Summary
Key Takeaways
- Measure first - Use Instruments to identify actual bottlenecks
- ARC awareness - Understand strong, weak, and unowned references
- Break retain cycles - Use weak self in closures, weak delegates
- Instruments profiling - Master Time Profiler, Allocations, Leaks, Network
- SwiftUI optimization - Minimize updates, use Equatable, lazy loading
- Image optimization - Downsample to display size, implement caching
- Network efficiency - Reduce payloads, cache responses, deduplicate requests
- Battery conservation - Avoid polling, batch operations, efficient background tasks
- Real device testing - Simulators don't reflect real performance
- Continuous profiling - Profile regularly during development, not just before release
Next Steps: Review iOS Testing for performance testing strategies and iOS Architecture for efficient architectural patterns.