Instruments for iOS developers: best practices explained
You've shipped your iOS app, but users are reporting that it freezes during certain workflows, or the battery drains faster than expected. The problem isn't obvious from logs alone—you need to see what's actually happening in memory, CPU, and network in real time. That's where Xcode's Instruments suite becomes indispensable. This guide walks through the essential profiling tools every iOS team uses to diagnose performance issues before they reach production.
Why profiling matters before optimizing
The biggest mistake developers make is optimizing blind. You'll spend hours refactoring a function, only to discover the real bottleneck was elsewhere. Profiling tells you exactly where your app spends CPU cycles, allocates memory, makes network requests, and stalls the UI thread. Without this data, optimization is guesswork.
Opening Instruments is simple: run your app in Xcode, then go to Xcode → Open Developer Tool → Instruments. You'll be presented with a template selection screen. Let's walk through the four instruments you'll reach for most often.
Time Profiler for CPU
The Time Profiler tells you which functions consume the most CPU time and how often they're called. It samples the call stack at regular intervals and builds a timeline of where your app is spending cycles.
Real-world scenario: Your list of products loads instantly, but scrolling through thousands of items feels janky. You suspect rendering is the culprit, but how bad is it really? Time Profiler shows the call stack and reveals that your ProductCell is calling a heavy image processing function on the main thread for every visible row.
Using Time Profiler:
- Select the Time Profiler template when launching Instruments
- Record while performing the slow operation (scrolling, filtering, searching)
- Stop recording and examine the call tree
- Look for high "Self %"—that's time spent in the function itself, not its children
- Expand entries to drill down into the call chain
The UI shows two main views: the timeline at the top and the call tree below. The call tree is your friend. Sort by "Self" to see which functions burn the most CPU. If you see a function taking 40% of CPU during your test, that's your target.
A common pattern: You'll often find that a function you thought was fast is being called hundreds of times per second. Sometimes the fix isn't to optimize the function—it's to call it less often.
// Example: Image processing on main thread during cell rendering
func imageWithAppliedFilter(_ image: UIImage) -> UIImage {
// Heavy filter computation
let context = CIContext()
let filter = CIFilter(name: "CIGaussianBlur")
filter?.setValue(CIImage(image: image), forKey: kCIInputImageKey)
filter?.setValue(10, forKey: kCIInputRadiusKey)
let output = filter?.outputImage ?? CIImage(image: image)
if let cgImage = context.createCGImage(output, from: output.extent) {
return UIImage(cgImage: cgImage)
}
return image
}
// Better: Process on background queue and cache
func cachedImageWithFilter(_ image: UIImage) -> UIImage {
let cacheKey = "filtered_\(image.hashValue)"
if let cached = filterCache[cacheKey] {
return cached
}
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let filtered = self?.imageWithAppliedFilter(image) ?? image
DispatchQueue.main.async {
self?.filterCache[cacheKey] = filtered
}
}
return image
}Move heavy work off the main thread, or cache results so you compute once instead of repeatedly.
Allocations and Leaks for memory
Memory issues are subtle. Your app might not crash immediately, but a slow memory leak will eventually trigger an out-of-memory termination. The Allocations instrument tracks every heap allocation, and the Leaks instrument identifies objects that are no longer referenced but still consume memory.
Real-world scenario: Your app runs fine for 10 minutes, then crashes. No error message—just a sudden termination. Users blame poor stability. You run Allocations and see memory climbing steadily from 150 MB to 500 MB as you open and close the same screen repeatedly. Something isn't being deallocated.
Using Allocations:
- Select Allocations from the template menu
- Use your app and trigger the suspected memory issue repeatedly
- Watch the "All Allocations" graph at the top
- If memory climbs and doesn't drop back down, you have a leak
- Click on the memory spike and examine the allocations list
- Use the search bar to filter by class name (search for your view controller, for instance)
The Leaks instrument goes further: it actually marks unreachable objects and tells you exactly which ones are leaked. Run it on top of Allocations to pinpoint the problem.
Common culprits:
- Strong reference cycles between view controller and delegate
- Timers not invalidated when view controller deallocates
- Closures capturing
selfstrongly in callbacks - Observers registered but never unregistered
// Memory leak: strong reference cycle
class DetailViewController: UIViewController {
var timer: Timer?
override func viewDidLoad() {
super.viewDidLoad()
// Captures self strongly, timer holds DetailViewController,
// DetailViewController holds timer = cycle
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.updateUI()
}
}
}
// Fixed: Invalidate timer in deinit
class DetailViewController: UIViewController {
var timer: Timer?
override func viewDidLoad() {
super.viewDidLoad()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.updateUI()
}
}
deinit {
timer?.invalidate()
}
}Use [weak self] in closures by default. Only use [unowned self] if you're absolutely certain the object outlives the closure.
Network instrument for requests
The Network instrument shows every HTTP request and response your app makes. You'll see headers, body content, timing, and response codes. It's invaluable for spotting unnecessary API calls or slow endpoints.
Real-world scenario: The product detail screen feels sluggish, and your backend team says the API responds in 100 ms. Yet the screen takes 2 seconds to display. Network instrument shows you're making 15 separate requests in series when you should bundle them into one. Or worse, you're making the same request twice because a view is loaded twice.
Using the Network instrument:
- Select Network from the template menu
- Use your app and perform network-dependent actions
- The bottom panel lists all requests chronologically
- Click a request to see full headers, body, and timing breakdown
- Look for duplicate requests or long gaps between requests (serial instead of parallel)
- Check response times and payload sizes
A common pattern: you navigate to a detail screen, and the view controller loads data in viewDidLoad. But if you push another view controller on top before the request finishes, then pop back, viewDidLoad runs again. Now you're fetching the same data twice. Use a cache layer or ensure requests aren't duplicated.
// Inefficient: every view load triggers a new request
class ProductDetailViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
fetchProductDetails()
}
func fetchProductDetails() {
URLSession.shared.dataTask(with: productURL) { data, response, error in
// Process
}.resume()
}
}
// Better: Cache and avoid duplicate requests
class ProductDetailViewController: UIViewController {
static let cache = NSCache()
override func viewDidLoad() {
super.viewDidLoad()
fetchProductDetailsIfNeeded()
}
func fetchProductDetailsIfNeeded() {
let cacheKey = NSString(string: productID)
if let cached = Self.cache.object(forKey: cacheKey) {
displayProduct(cached)
return
}
URLSession.shared.dataTask(with: productURL) { [weak self] data, response, error in
if let data = data {
Self.cache.setObject(data as NSData, forKey: cacheKey)
self?.displayProduct(data)
}
}.resume()
}
}Hangs instrument for UI stalls
A hang is when the main thread is blocked for 250 ms or longer. Users notice this as a frozen UI. The Hangs instrument captures every time the main thread stalls and shows you exactly which function is responsible.
Real-world scenario: Users report that tapping a button sometimes causes a half-second freeze. It's not a crash, but it's noticeable and annoying. Hangs instrument records a trace during the freeze and shows you a function parsing a large JSON file on the main thread.
Using the Hangs instrument:
- Select "Hangs" from the template menu (or "Main Thread Checker" in some Xcode versions)
- Set the sensitivity threshold—typical values are 250 ms to 1 second
- Use your app and try to trigger the freeze
- Each hang event appears in the timeline with a stack trace
- Examine the backtrace to find the main thread bottleneck
The fix is almost always the same: move work off the main thread.
// Hangs the main thread: JSON parsing and sorting
func processLargeDataSet(_ jsonData: Data) {
let decoder = JSONDecoder()
let items = try? decoder.decode([Item].self, from: jsonData)
let sorted = items?.sorted { $0.name < $1.name } ?? []
updateUI(with: sorted)
}
// Fixed: Parse on background queue
func processLargeDataSet(_ jsonData: Data) {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let decoder = JSONDecoder()
let items = try? decoder.decode([Item].self, from: jsonData)
let sorted = items?.sorted { $0.name < $1.name } ?? []
DispatchQueue.main.async {
self?.updateUI(with: sorted)
}
}
}Common mistakes
- Not recording long enough: A 5-second profile might miss an issue that happens every 30 seconds. Record for at least 30 seconds during normal usage patterns.
- Profiling with a debugger attached: The debugger slows everything down. Run with Release build configuration for accurate timings.
- Ignoring small spikes: A 10 MB memory spike from a single screen might not seem bad, but if it happens during every navigation, it compounds. Track cumulative impact.
- Confusing high "All Allocations" with a leak: Memory allocations can grow without being leaks—caches are intentional allocations. The Leaks instrument specifically identifies unreachable objects.
- Profiling production users: Always profile on your own device first. Production profiling can impact user experience and battery life.
Key takeaways
- Profile before optimizing. Data beats intuition—Instruments shows you exactly where time and memory are spent.
- Use Time Profiler to find CPU hotspots. Sort by "Self %" and look for functions called frequently or with high self-time.
- Use Allocations and Leaks to catch memory issues. Watch for climbing memory graphs and use the Leaks instrument to pinpoint unreachable objects.
- Use Network to identify duplicate or slow requests. Serial requests should often be parallel, and unnecessary API calls should be cached.
- Use Hangs to find main thread stalls. Any heavy work—JSON parsing, sorting, image processing—must run off the main thread.
- Make profiling part of your regular development workflow, not a last resort. Catch issues early when they're cheap to fix.






Comments
Loading comments…