iOS interview questions: real-world lessons and examples
When you're interviewing for an iOS role at a company running production apps at scale—whether it's e-commerce, SaaS, or ride-hailing—the technical questions go beyond syntax and framework mechanics. They probe how you think about state management, architecture, and debugging under real-world constraints. In this piece, we'll walk through four cornerstone iOS interview questions that senior engineers encounter, grounded in patterns we've seen work in production.
How does SwiftUI update views?
This question often trips up mid-level developers because the answer isn't just "State changes trigger re-renders." The real mechanism is more nuanced: SwiftUI compares dependency graphs before and after a state change, then re-evaluates only the views whose dependencies shifted. Understanding this distinction changes how you write efficient UI code.
When you mark a property with @State, SwiftUI tracks it as a dependency. When that property mutates, SwiftUI re-evaluates the body of any view that reads it. But here's the critical part: SwiftUI uses value comparison for structs. If your state property doesn't actually change value, the view body won't re-compute.
struct ProductListView: View {
@State private var selectedCategory: String = "All"
@State private var products: [Product] = []
@State private var isLoading: Bool = false
var body: some View {
VStack {
CategoryPicker(selectedCategory: $selectedCategory)
if isLoading {
ProgressView()
} else {
List(products) { product in
ProductRow(product: product)
}
}
}
.onChange(of: selectedCategory) { oldValue, newValue in
Task {
isLoading = true
products = await fetchProducts(for: newValue)
isLoading = false
}
}
}
}
In this example, when selectedCategory changes, SwiftUI re-evaluates the entire body. The List, the ProgressView, and the conditional logic all get re-computed. If your product list is large or your ProductRow is expensive to render, this can stutter. The solution is to break views into smaller components and use @State and property bindings precisely at the level where the change matters.
A common follow-up question: "What's the difference between @State and @ObservedObject?" @State is for simple, view-local value types. @ObservedObject (now often @Observable in iOS 17+) is for reference types that conform to observation protocols and live outside the view hierarchy. The key is that SwiftUI subscribes to the observable object's publisher and re-evaluates dependent views when properties change.
How do you structure an app?
Interviewers ask this to see whether you've struggled with real codebases and developed opinions about modularity, testability, and scaling. The answer should reflect practical trade-offs, not theoretical ideals.
A solid structure typically separates concerns into three layers: UI, business logic (usually via observable models or ViewModels), and services. Services hide implementation details behind protocols, making them mockable and swappable. Feature modules group related screens, view models, and local services.
// MARK: - Protocol-based service layer
protocol ProductServiceProtocol {
func fetchProducts(category: String) async throws -> [Product]
func searchProducts(query: String) async throws -> [Product]
}
class ProductService: ProductServiceProtocol {
private let apiClient: APIClientProtocol
private let cache: CacheServiceProtocol
init(apiClient: APIClientProtocol, cache: CacheServiceProtocol) {
self.apiClient = apiClient
self.cache = cache
}
func fetchProducts(category: String) async throws -> [Product] {
if let cached = try cache.fetch(forKey: category) as? [Product] {
return cached
}
let products = try await apiClient.get("/products?category=\(category)")
try cache.store(products, forKey: category)
return products
}
}
// MARK: - Observable ViewModel
@Observable
class ProductListViewModel {
var products: [Product] = []
var isLoading: Bool = false
var error: String?
private let productService: ProductServiceProtocol
init(productService: ProductServiceProtocol) {
self.productService = productService
}
@MainActor
func loadProducts(category: String) async {
isLoading = true
error = nil
do {
products = try await productService.fetchProducts(category: category)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
// MARK: - View
struct ProductListView: View {
@State var viewModel: ProductListViewModel
var body: some View {
List(viewModel.products) { product in
ProductRow(product: product)
}
.task {
await viewModel.loadProducts(category: "Electronics")
}
}
}
Notice the dependency injection: the ViewModel receives its service via the initializer, not by creating it directly. This makes testing trivial—you inject a mock service and verify behavior. For larger apps, use a container to manage dependencies at app launch.
At the feature level, group all related files in a single directory or package: views, view models, models, and local networking logic. This keeps navigation mental overhead low and makes it easy to extract features into frameworks later if the app grows.
How do you handle background work?
This question separates engineers who've shipped production apps from those who've only worked on personal projects. Background work in iOS involves multiple APIs, each with specific use cases and constraints.
BGTaskScheduler is for work that doesn't need to happen at an exact time. You schedule a task, the OS defers it (often to after the user plugs in the device or when the device is on WiFi), and your app gets a few seconds to run code and call completion. This is ideal for syncing data, refreshing cached content, or cleaning up local databases.
// In AppDelegate or in your app's entry point
import BackgroundTasks
func scheduleDatabaseCleanup() {
let request = BGProcessingTaskRequest(identifier: "com.myapp.cleanup")
request.requiresNetworkConnectivity = false
request.requiresExternalPower = true
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Failed to schedule cleanup: \(error)")
}
}
// Register the task handler early (in AppDelegate.application(_:didFinishLaunchingWithOptions:))
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.myapp.cleanup", using: nil) { task in
handleDatabaseCleanup(task: task as! BGProcessingTask)
}
func handleDatabaseCleanup(task: BGProcessingTask) {
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
let operation = BlockOperation {
// Perform cleanup
Database.shared.removeOldRecords()
}
operation.completionBlock = {
task.setTaskCompleted(success: !operation.isCancelled)
}
task.expirationHandler = {
queue.cancelAllOperations()
}
queue.addOperation(operation)
}
Background URLSession is for downloads and uploads that may outlive your app. You create a session with a background configuration, add tasks, and the OS manages them even if your app is suspended. When tasks complete, the OS wakes your app (via a callback in AppDelegate) to handle the results.
func startBackgroundUpload(data: Data, endpoint: String) {
let config = URLSessionConfiguration.background(
withIdentifier: "com.myapp.upload.\(UUID().uuidString)"
)
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
var request = URLRequest(url: URL(string: endpoint)!)
request.httpMethod = "POST"
let task = session.uploadTask(with: request, from: data)
task.resume()
}
// In AppDelegate
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
// Reconnect to the session and re-attach your delegate
let session = URLSession(configuration: .background(withIdentifier: identifier))
session.getAllTasks { tasks in
// Handle task completion
completionHandler()
}
}
Local and remote notifications often trigger background work too. When a notification arrives, your app can use a notification service extension to download content before the notification is shown, or it can respond to user actions in a notification content extension.
The key distinction: BGTaskScheduler is for periodic maintenance; background URLSession is for long-running transfers; notifications are for user-facing events that may involve background processing. Choose the right tool for your use case, and always test on a real device—the simulator doesn't faithfully reproduce background behavior.
How do you debug performance?
Performance problems in production are rarely guesses. Senior engineers reach for Instruments, a suite of profiling tools built into Xcode, to measure and fix issues methodically.
Time Profiler shows where your app spends CPU time. You run it, interact with your app normally, and it captures a timeline of which functions consumed CPU. If your list scrolling stutters, Time Profiler reveals whether it's a rendering bottleneck (Metal/Core Graphics), data fetching, or business logic.
Allocations tracks memory use over time. If your app's memory grows unbounded, Allocations shows you which types are proliferating. You can filter by heap size to see large allocations, then drill down to the allocation stack to see where they were created. This is invaluable for finding memory leaks in view models, image caches, or delegate cycles.
Hangs (on recent iOS versions) detects jank—frames that took longer than 16.7ms to render, causing visible stutter. It's often caused by main-thread work: heavy computations, synchronous file I/O, or slow data transformations. The tool shows you exactly which code ran during the hang.
A practical debugging workflow: reproduce the performance problem, attach Instruments (Product > Profile in Xcode), choose the relevant instrument, run for 30 seconds to 1 minute, then stop recording. Look for peaks, anomalies, or sustained high usage. Common culprits are list cells that load images synchronously, view model computations on the main thread, and excessive SwiftUI view re-renders.
// MARK: - Common performance mistake: synchronous image loading in a list cell
struct ProductRow: View {
var product: Product
@State private var image: UIImage?
var body: some View {
HStack {
// DON'T do this: synchronous load will block main thread
if let image = image {
Image(uiImage: image)
.resizable()
.frame(width: 60, height: 60)
}
VStack(alignment: .leading) {
Text(product.name)
Text(product.price)
}
}
.onAppear {
// WRONG: blocking call on main thread
image = loadImageSynchronously(from: product.imageURL)
}
}
}
// MARK: - Fixed version: load asynchronously
struct ProductRow: View {
var product: Product
@State private var image: UIImage?
var body: some View {
HStack {
if let image = image {
Image(uiImage: image)
.resizable()
.frame(width: 60, height: 60)
} else {
ProgressView()
.frame(width: 60, height: 60)
}
VStack(alignment: .leading) {
Text(product.name)
Text(product.price)
}
}
.task {
// Correct: async load off main thread
image = await loadImageAsynchronously(from: product.imageURL)
}
}
}
Common mistakes
- Holding strong references in callbacks. If a view model's completion handler captures
selfwithout a weak reference, you create a retain cycle. Always use[weak self]in escaping closures. - Creating ObservedObjects or @State on every view update. If you initialize a view model in a view's body, it gets re-created constantly, losing state. Move initialization to init or use
@StateObject. - Ignoring thread safety. SwiftUI expects state mutations on the main thread. If you fetch data on a background thread, always call
@MainActormethods or dispatch back to the main queue before updating state. - Not testing background modes locally. Background functionality often fails silently in production. Test with Xcode's scheme settings to simulate background launches, and check system logs on the device.
- Over-optimizing prematurely. Profile first; guess last. Many "performance fixes" add complexity without real impact. Let Instruments guide your efforts.
Key takeaways
- SwiftUI updates views by re-evaluating the body of views whose state dependencies changed; minimize re-evaluation by breaking views into smaller, focused components.
- Separate UI, business logic (view models), and services behind protocols to enable testing, modularity, and swapping implementations.
- Background work has multiple APIs: BGTaskScheduler for periodic maintenance, background URLSession for long-running transfers, and notifications for user-facing events.
- Use Instruments (Time Profiler, Allocations, Hangs) to measure performance objectively; never rely on intuition for bottleneck identification.
- Common interview mistakes stem from overlooking retain cycles, initializing state objects incorrectly, ignoring thread safety, and not testing background behavior on real devices.






Comments
Loading comments…