This page is for anyone preparing for an iOS developer interview, from a first job to a senior role. Most rounds start with your path and an app you built, then test Swift basics like optionals, value types and protocols, memory and retain cycles, SwiftUI state and the UIKit lifecycle, and concurrency from Grand Central Dispatch to async/await and actors. Stronger rounds add networking, Core Data, testing, release stories and judgement calls. Each question shows what the interviewer is checking, the shape of a good answer and a short answer you can say out loud. Swap in your own apps and stories.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Start: how you first got into building for iPhone, in one or two sentences.
Shipped: the apps or features you built, and whether real users touched them.
Next: what you want to get better at and why this role helps.
"I started with Swift in my final year because I wanted to build a study timer for my friends, and I got hooked on how fast you see your work on a real phone. That app went on the App Store and picked up a few hundred users, which taught me about review, crash reports and people using it in ways I didn't expect. After that I joined a small agency where I worked on two client apps, mostly in UIKit, with newer screens in SwiftUI. I built login, a feed with image caching, and push notification handling. What I want next is a product team where I own features for longer and get deeper into concurrency and performance, not just ship and hand over."
Listing tutorials and courses with nothing built for real users, or being unable to say what you personally did on a team app.
Product: what the app does and who uses it, in one sentence.
Your part: the screens or systems you owned, not the whole team.
Hard part: one real problem, the options you had and what you chose.
"The one I'm proudest of is a field inspection app I built at my last company. Inspectors used it on sites with bad signal, so it had to work fully offline. I owned the data layer and the sync. The hardest part was sync conflicts, when two inspectors edited the same report. We looked at last-write-wins, but that silently lost notes, so I stored changes per field with a timestamp and merged field by field, and only asked the user when the same field changed on both sides. I also moved the upload to a background URLSession so large photo batches finished even if the inspector locked the phone. Complaints about lost work pretty much stopped after that release."
Describing the product for two minutes and never getting to a decision you made, or saying "we" so often the interviewer cannot tell what you did.
The problem: what in the design fought the platform, such as gestures, accessibility or navigation.
How you raised it: show, do not argue; a prototype, the platform guidance, a real device.
Outcome: what you agreed and what you learned about working with that designer.
"On my last team, a designer gave me a custom bottom sheet with its own swipe gestures and a hand-drawn dimmed background. When I built it, the swipe fought with the system back gesture, and it didn't work well with larger text sizes or VoiceOver. Instead of saying it couldn't be done, I built two quick versions, his custom one and one using the system sheet with detents styled to match his colours and corners. We tried both on a real phone together, including with large text turned on. He liked that the system one felt native and got accessibility for free, and we kept his visual style. After that he started asking me to look at interaction ideas early, before designs were final, which saved us both a lot of rework."
Saying you just built whatever the design said, or that you overruled the designer without showing them why.
Rank the issues: the data race is a must-fix; force unwraps depend on whether nil is truly impossible.
Explain concretely: say what can crash and when, and suggest a specific safer pattern.
Keep it kind: comment on the code, offer to pair, and turn repeated issues into a team rule or lint.
"I'd sort the problems first. The singleton being changed from background threads is a real data race, and those crash randomly and are painful to trace, so that's a must-fix before merging. I'd explain the exact scenario, suggest making that state an actor or confining it to the main actor, and mention that Thread Sanitizer would show it. The force unwraps I'd look at one by one. If a value can genuinely be nil, like data from the network, I'd suggest a guard let with a proper error path. If it's something like a URL built from a constant string, a force unwrap is fine and I'd say so. I keep comments about the code, not the person, and offer to pair if it's a bigger change. If it keeps coming up, I'd suggest a lint rule so reviews aren't personal."
Approving it to avoid friction, or leaving a wall of nit comments with the real data race buried among them.
How you learn: conference sessions, release notes, Swift Evolution and small experiments.
Filter: does it fix a real problem for our users or code, and does our minimum iOS version allow it?
Adopt safely: start with one small, low-risk area and share what you learned with the team.
"Every year after the new SDK is announced, I watch the sessions that touch what we actually build, and I read the release notes for the frameworks we use most. For Swift itself I skim the evolution proposals that are accepted, because that's where language changes are explained properly. Then I try the interesting parts in a small side project, not in the main app. For production, my filter is simple: does it solve a problem we really have, and can we use it given our minimum iOS version? If yes, I try it in one small, low-risk screen first and write a short note for the team on what worked. For example, we moved to async/await on new code first, and only later converted older code where it was touched anyway."
Saying you do not follow changes at all, or rewriting working code every time a new framework appears.
As author: small pull requests with a clear description, screenshots or a video for UI changes.
As reviewer: focus on correctness, threading, memory, accessibility and readability; leave style to tools.
Tone: explain the why, mark nits as nits, and treat feedback on your code as help, not judgement.
"When I send code for review, I try to keep the pull request small and say what changed and why, and for UI work I add screenshots or a short screen recording, including dark mode and a large text size. When I review, I focus on the things tools can't catch: is the logic right, is anything touching the UI off the main thread, could a closure create a retain cycle, does it work with VoiceOver, and will the next person understand it. Formatting and naming conventions I'd rather leave to a linter, so we don't argue about them. I explain the reason behind a comment and mark small things as optional. When my own code gets comments, I assume good intent, ask questions if I don't agree, and change it if they're right."
Treating review as a formality that only checks formatting, or getting defensive about every comment.
What it is: a type that holds either a value or nil; under the hood an enum with some and none.
Safe unwrapping: if let, guard let, ?? for a default, and optional chaining with ?..
Force unwrap: ! crashes on nil, so keep it for cases that truly cannot be nil.
"An optional says a value might be missing. String? means either a string or nil, and the compiler won't let me use it as a plain string until I deal with the nil case. Under the hood it's an enum with two cases, some and none. To unwrap safely I use if let when I only need the value in one branch, and guard let at the top of a function when I want to exit early and keep the unwrapped value for the rest. For a fallback I use the nil-coalescing operator, and for a chain of properties I use optional chaining, which just returns nil if any link is missing. A force unwrap with an exclamation mark crashes on nil, so I avoid it unless nil would be a programmer error."
func greeting(for name: String?) -> String {
guard let name, !name.isEmpty else {
return "Hello, guest"
}
return "Hello, \(name)"
}
let city = user.address?.city ?? "Unknown"
Scattering force unwraps everywhere, or saying an optional is "just a nullable pointer" with no idea the compiler forces you to handle nil.
map do on an optional, and when would you use it instead of if let?Value type: assigning a struct gives an independent copy; changing one never changes the other.
Reference type: assigning a class shares one instance; it has identity, inheritance, deinit and ARC.
Default: start with a struct; pick a class when you need shared mutable state, identity or inheritance.
"Structs are value types, so when I assign one to another variable or pass it into a function, I get an independent copy. If I change the copy, the original doesn't change. Classes are reference types, so two variables can point at the same instance and a change through one shows up through the other. Classes also give me identity checks, inheritance and deinit, and they're managed by ARC. My default is a struct for models and view state because it's easier to reason about and safer across threads. The standard collections like Array and Dictionary use copy-on-write, so copying them is cheap until one side actually changes. I reach for a class when something needs to be shared and mutated in one place, like a cache or a view model several screens observe."
Saying the only difference is stack versus heap, or not knowing that mutating a copied class instance changes the original.
Protocol: a contract of requirements any struct, class or enum can adopt.
Extension: adds shared behaviour, including default implementations of requirements.
The trap: a method only in the extension, not declared in the protocol, is chosen by the static type, so a conforming type cannot really override it.
"A protocol is a contract, and any type can adopt it. With an extension I can give every conforming type a default implementation, so I get shared behaviour without a base class, and structs and enums can use it too. I use this a lot for things like a Loggable or a reusable-cell identifier. The surprise is dispatch. If the method is declared in the protocol itself, calls go through the conforming type's version even when the variable is typed as the protocol. But if the method only lives in the extension and isn't a requirement, Swift picks the version from the static type. So if my variable is typed as the protocol, it calls the extension version even though the concrete type has its own. The fix is to declare it in the protocol."
protocol Greeter {
func greet() -> String // requirement
}
extension Greeter {
func greet() -> String { "Hello" } // default implementation
func wave() -> String { "Wave" } // extension only, not a requirement
}
struct Friendly: Greeter {
func greet() -> String { "Hi there" }
func wave() -> String { "Big wave" }
}
let g: any Greeter = Friendly()
print(g.greet()) // Hi there
print(g.wave()) // Wave
Treating protocols as Java-style interfaces only, with no idea that extensions can supply behaviour or that extension-only methods are statically chosen.
Default capture: a closure captures variables themselves, so it sees later changes and holds class instances strongly.
Capture list: [x] copies the value at creation; [weak self] or [unowned self] changes how self is held.
When it matters: when self stores the closure, directly or through something it owns, and the closure uses self.
"By default a closure captures the variables it uses, not a snapshot, so if the variable changes later the closure sees the new value, and any class instance it touches is held strongly. A capture list lets me change that. Writing a variable in square brackets copies its value at the moment the closure is created, and writing weak self means the closure won't keep self alive. I need weak self when there's a cycle: self owns something that stores the closure, and the closure uses self. Stored completion handlers, timers and notification blocks are the usual cases. A non-escaping closure like the one passed to map can't outlive the call, so it can't cause a cycle. A one-off async call doesn't cause a cycle either, it just keeps self alive a bit longer."
var count = 1
let byReference = { print(count) }
let byValue = { [count] in print(count) }
count = 2
byReference() // 2
byValue() // 1
Adding [weak self] to every closure by habit without being able to explain where the cycle is, or saying closures copy every variable they use.
ARC: counts strong references to each class instance and frees it when the count reaches zero; there is no cycle collector.
Retain cycle: two objects hold each other strongly, so neither count reaches zero.
Weak vs unowned: weak is optional and becomes nil when the object goes; unowned is non-optional and crashes if used after the object is freed.
"ARC keeps a count of strong references to every class instance. The compiler inserts the retains and releases, and when the count drops to zero the object is freed and deinit runs. It doesn't find cycles for you, so if a view controller holds a closure and that closure holds the view controller, neither ever goes away. I break a cycle by making one side not count. A weak reference is always optional and is set to nil automatically when the object is freed, so it's safe when the other object might go away first, which is why delegates are weak. Unowned doesn't make it optional, but if I touch it after the object is gone, the app crashes. I only use unowned when the other object is guaranteed to live at least as long, and weak whenever I'm unsure."
final class Loader {
var onFinish: (() -> Void)?
}
final class ProfileViewController: UIViewController {
let loader = Loader()
override func viewDidLoad() {
super.viewDidLoad()
loader.onFinish = { [weak self] in
self?.title = "Loaded" // no cycle: the closure does not own self
}
}
}
Saying ARC is a garbage collector that cleans up cycles, or choosing unowned just to avoid unwrapping.
Symptom: what you noticed, such as memory climbing or a screen doing work after it closed.
Tools: how you found it, for example a deinit log, the memory graph debugger or Instruments.
Cause and proof: the exact reference cycle, the fix, and how you confirmed the object is now freed.
"At my last company, QA noticed memory climbing each time they opened and closed the chat screen, and sometimes old screens were still reacting to incoming messages. I added a log in the view controller's deinit and it never printed. Then I opened Xcode's memory graph debugger after closing the screen a few times and saw several copies of the controller still alive. The graph showed a shared socket manager holding a message handler closure, and that closure captured the controller strongly. So the singleton kept every chat screen alive. I changed the closure to capture self weakly and removed the handler when the screen closed. To prove it, deinit started printing, the memory graph showed one controller at a time, and I added a small test that checks a weak reference to the controller becomes nil after it's dismissed."
A vague story with no tool and no specific cycle, or claiming the fix was restarting the app or clearing caches.
Measure first: reproduce on a real, older device and profile with Instruments, such as Time Profiler.
Find the cause: usually heavy work on the main thread, like image decoding, formatting or complex layout.
Fix and verify: move work off main, cache it, then profile again on the same device.
"At my last company our product feed stuttered badly on older phones, and it was the first screen people saw. I reproduced it on the oldest device we supported and ran Time Profiler while scrolling. Two things stood out on the main thread. We were decoding full-size product photos, several times larger than the cell, and each cell was creating a new date formatter. I moved image decoding and downsampling to a background task, so we only decoded an image at the size it's actually shown, cached the results, and made the formatter a shared static. I also used the prefetching API to start loading images a few rows ahead. Then I profiled again on the same phone, and the long main thread spikes were gone and scrolling felt smooth. I kept that old phone in our release checklist after that."
Guessing at the cause and changing code without profiling, or only testing on the newest phone.
@State: a simple value the view owns; SwiftUI stores it outside the struct so it survives redraws.
@Binding: read and write access to state owned by a parent, passed with a dollar-sign prefix.
Objects: @StateObject when this view creates and owns an ObservableObject; @ObservedObject when it is handed one owned elsewhere.
"The question I ask is who owns the data. If it's a small value that only this view cares about, like whether a sheet is showing, I use State, and SwiftUI keeps it alive even though the view struct is recreated all the time. If a child needs to read and change a parent's value, the child takes a Binding and the parent passes it with a dollar sign. For a reference type model, if this view creates it, it's StateObject, which is created once for the view's lifetime. If the object is created somewhere else and passed in, it's ObservedObject. The classic bug is creating a model inside a view with ObservedObject. Every time the parent redraws, the model can be recreated and the screen loses its data, like a half-filled form resetting."
struct CounterScreen: View {
@StateObject private var model = CounterModel() // this view owns it
@State private var showHelp = false
var body: some View {
VStack {
CounterLabel(model: model)
Toggle("Show help", isOn: $showHelp)
}
}
}
struct CounterLabel: View {
@ObservedObject var model: CounterModel // owned by the parent
var body: some View { Text("\(model.count)") }
}
Using @ObservedObject for an object the view creates itself, or passing a plain value where the child needs a binding and then wondering why changes are lost.
Tracking: @Observable tracks each property a view actually reads in body, instead of one change signal for the whole object.
Less code: no @Published; stored properties are tracked unless marked @ObservationIgnored.
Ownership: own it with @State, pass it as a plain property, use @Bindable for bindings and @Environment to share it.
"With ObservableObject, any change to any Published property fires objectWillChange, and every view observing that object redraws, even if it only shows one field. With the Observable macro, SwiftUI records which properties a view actually reads in its body and only redraws when one of those changes, so big shared models get much cheaper. I also drop all the Published wrappers. Ownership changes too. A view that creates the model holds it in State instead of StateObject, children just take it as a normal property, and if a child needs a binding to one of its fields, it marks the property Bindable. For app-wide models I use the environment with the type as the key. One thing to watch: the model's initializer can run again whenever the parent rebuilds the view, even though SwiftUI keeps the first instance, so the init should stay cheap."
@Observable
final class ProfileModel {
var name = ""
var bio = ""
}
struct ProfileScreen: View {
@State private var model = ProfileModel()
var body: some View { ProfileForm(model: model) }
}
struct ProfileForm: View {
@Bindable var model: ProfileModel
var body: some View {
TextField("Name", text: $model.name)
}
}
Saying it is just a renamed ObservableObject, or still reaching for @StateObject and @Published with an @Observable model.
model.name, does it redraw when model.bio changes, and why?Why and what: the concrete reason and which parts you chose to move first, and which you left alone.
Bridges: how old and new code lived together, such as UIHostingController or continuations.
Safety: tests, flags and release steps that kept users safe during the move.
"At my last company we had a large UIKit app and wanted new screens in SwiftUI without a rewrite. I proposed going screen by screen, starting with settings and profile, which were simple and rarely broke. Navigation stayed in UIKit, and each new SwiftUI screen was wrapped in a hosting controller, so the rest of the app didn't know the difference. For the networking layer we moved from callbacks to async/await in the same way. I added async versions next to the old methods using checked continuations, being careful to resume exactly once on every path, and moved callers over a few at a time. Each migrated screen went out behind a remote flag so we could switch back without a new release. We deliberately left the complex chat screen in UIKit, because moving it would have cost a lot and fixed nothing users felt."
Proposing a full rewrite as the first option, or migrating everything at once with no way to switch back.
Once: loadView builds the view, viewDidLoad runs once after it loads; do one-time setup there.
Each time it shows: viewWillAppear, viewDidAppear, then viewWillDisappear, viewDidDisappear.
Layout: viewDidLayoutSubviews can run many times; frames are not final in viewDidLoad.
"First the controller is created, then loadView creates its view, and viewDidLoad runs once after that. That's where I add subviews, set constraints and wire up bindings. The bounds aren't final yet, so I don't do frame math there. Every time the screen is about to show, viewWillAppear runs, which is where I refresh data that might have changed while it was hidden. viewDidAppear runs once it's on screen, so I start animations or log the screen view there. Layout callbacks like viewDidLayoutSubviews can run many times, for rotation or size changes, so anything in them has to be cheap and safe to repeat. When it leaves, viewWillDisappear and viewDidDisappear run, where I pause timers or stop observing. Finally deinit runs when nothing holds it anymore."
Doing frame-based layout in viewDidLoad, or adding observers in viewWillAppear without removing them, so they stack up each visit.
Why: UIKit and SwiftUI are not thread-safe; the main thread runs the event loop that draws and handles touches.
Pattern: do heavy work on a background queue, then hop back with DispatchQueue.main.async for UI.
Traps: calling DispatchQueue.main.sync from the main thread deadlocks; the Main Thread Checker catches UI calls off main.
"UIKit isn't thread-safe, and the main thread runs the loop that handles touches and draws the screen. If I update views from another thread I get random glitches or crashes, and if I do slow work on the main thread the app freezes. So the pattern is to send heavy work like parsing a big file or resizing images to a global queue with a suitable quality of service, and when it's done, dispatch back to the main queue to update the UI. The main queue is serial, so UI updates happen in order. One classic mistake is calling sync on the main queue while already on the main thread, which deadlocks because it waits for itself. In debug builds Xcode's Main Thread Checker flags UI calls made off the main thread, which catches most of these early."
DispatchQueue.global(qos: .userInitiated).async {
let thumbnail = makeThumbnail(from: largeImage) // slow work
DispatchQueue.main.async {
imageView.image = thumbnail // UI on main
}
}
Not knowing why the main thread matters, or suggesting main.sync as a way to update UI from background code.
Readability and safety: code reads top to bottom, errors use throws, and you cannot forget to call a callback.
Parallel: async let or a task group start work together; await collects results.
Cancellation: it is cooperative; child tasks are cancelled with the parent, and code checks Task.isCancelled or throws.
"With completion handlers, the flow jumps around, every path has to remember to call the handler exactly once, and errors are passed as optionals. With async/await the code reads top to bottom, errors are thrown and caught normally, and the compiler makes sure every path returns or throws. An await is a suspension point: the thread is freed for other work while we wait, and the function may resume on a different thread unless it's isolated to an actor like the main actor. To load a profile and its posts together, I use async let for both and then await both results, so they run in parallel. Cancellation is cooperative. If the parent task is cancelled, the children are too, and URLSession's async methods throw when cancelled, but my own long loops need to check for cancellation themselves."
func loadScreen(userID: Int) async throws -> (User, [Post]) {
async let user = api.fetchUser(id: userID)
async let posts = api.fetchPosts(userID: userID)
return try await (user, posts) // both run at the same time
}
Saying await blocks the thread, or assuming code after an await is still on the main thread without any actor isolation.
Isolation: an actor lets only one task touch its mutable state at a time; outside callers must await.
Reentrancy: at every await inside an actor method, other calls can run, so state may change across the suspension.
@MainActor: a global actor for the main thread; put it on UI code and view models.
"Actors protect mutable state from data races. Only one task runs on an actor's state at a time, and code outside the actor has to await to reach it, so the compiler catches unsafe access instead of me finding it in a crash log. The catch is reentrancy. When an actor method hits an await, it gives up the actor, and other calls can run in between. So anything I checked before the await might be different after it. A classic example is an image loader: two callers ask for the same URL, both see it's not cached, and both download it. The fix is to store the in-flight Task before awaiting, so the second caller awaits the same task. MainActor is a global actor tied to the main thread, and I mark view models and UI code with it so updates always land on main."
actor ImageLoader {
private var tasks: [URL: Task<Data, Error>] = [:]
func data(for url: URL) async throws -> Data {
if let existing = tasks[url] {
return try await existing.value
}
let task = Task {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
tasks[url] = task // stored before awaiting, so callers share it
return try await task.value
}
}
Believing an actor method cannot be interleaved with other calls, or putting everything on the main actor to silence compiler warnings.
Debounce: on each keystroke cancel the previous task, sleep briefly, then search.
Stale results: check cancellation after the sleep and again after the network call, before updating state.
Isolation: mark the view model @MainActor so published results change on the main thread.
"I'd keep a reference to the current search task. Every time the text changes, I cancel that task and start a new one. The new task sleeps for a short delay first, and if the user types again during that time, it gets cancelled and never reaches the API. That's the debounce. The subtle part is stale results. A slow response for an old query could arrive after a newer one, so after the network call I check again whether this task was cancelled, and only then assign the results. Because the whole view model is on the main actor, the task it creates runs there too, so the results update on the main thread. The service protocol is Sendable so it's safe to call from the task. Passing it in also makes this easy to test with a fake."
protocol SearchService: Sendable {
func search(_ text: String) async throws -> [String]
}
@MainActor
final class SearchViewModel: ObservableObject {
@Published private(set) var results: [String] = []
private var searchTask: Task<Void, Never>?
private let service: any SearchService
init(service: any SearchService) { self.service = service }
func queryChanged(_ text: String) {
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
let found = (try? await service.search(text)) ?? []
guard !Task.isCancelled else { return }
results = found
}
}
}
Firing a request on every keystroke and assigning whatever comes back last, which shows old results when responses arrive out of order.
Model: a struct conforming to Decodable, with optionals for fields that may be missing.
Key mapping: a CodingKeys enum, or a key decoding strategy like convertFromSnakeCase.
Fetch: URLSession, check the HTTP status, then decode with JSONDecoder and handle errors.
"I'd make a struct that conforms to Decodable, with a property for each field I need. If the API sends snake case, like full underscore name, I either add a CodingKeys enum that maps each property to its JSON key, or set the decoder's key strategy to convert from snake case. Fields that might be missing become optionals, and the synthesized decoder then treats a missing key as nil instead of failing. For the request, I use URLSession's async data method, check the response is an HTTP status in the 200 range, and only then decode. If decoding fails, the DecodingError tells me which key or type was wrong, which I log in debug builds because it saves a lot of guessing. Dates need their own strategy, like ISO 8601, set on the decoder."
struct User: Decodable {
let id: Int
let fullName: String
let avatarURL: URL?
enum CodingKeys: String, CodingKey {
case id
case fullName = "full_name"
case avatarURL = "avatar_url"
}
}
func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(User.self, from: data)
}
Parsing JSON by hand into dictionaries with force casts, or decoding without ever checking the HTTP status code.
Pieces: the model is the schema, the persistent container sets up the store, and a managed object context is the workspace where you fetch, change and save.
Threading rule: each context belongs to one queue; work inside perform, and never pass managed objects between contexts.
Background import: use a background context, pass object IDs across, and let the view context merge changes.
"Core Data has a model, which describes the entities, a persistent store, usually SQLite, and managed object contexts, which are like scratchpads where I fetch objects, change them and then save. NSPersistentContainer sets all of that up. The rule that matters most is that a context and its objects belong to one queue. The view context runs on the main queue for the UI. For a big import I create a background context, or use performBackgroundTask, and do all the work inside perform so it runs on that context's queue. I never hand a managed object to another thread. If I need it elsewhere, I pass its object ID and fetch it again in the other context. I turn on automatic merging on the view context so the UI picks up background saves, and set a merge policy for conflicts."
Using the main view context for a large import, or passing NSManagedObject instances between threads.
Save first: persist the draft locally as the user types and when the app goes to the background.
Send from an outbox: queue the submission locally, send it, and retry with backoff when the network returns.
No duplicates, clear status: a unique request ID so retries are safe, and a visible pending or failed state.
"I'd start by reproducing it with the Network Link Conditioner, and I'd bet the form only lives in memory, so a failed request or the app being suspended wipes it. First, I'd save the draft locally as the user types, with a short debounce, and again when the scene goes to the background. Second, submit would write the item to a small local outbox before sending. If the request fails, it stays there and I retry with backoff when the network path monitor says we're back online. Third, each item gets a unique ID that the backend uses to ignore duplicates, because a request can succeed on the server while the response is lost on the way back. In the UI I'd show a clear pending state and a failed state with retry, so people trust that their work is safe."
Only adding an error alert that says try again, or retrying blindly without any protection against duplicate submissions.
States: not running, inactive, active, background and suspended.
Scenes: the scene delegate or SwiftUI scenePhase handles each window’s foreground and background; the app delegate handles process-level events.
On background: save user work and pause what is running; a suspended app can be ended without warning.
"An app can be not running, inactive, active, in the background or suspended. Inactive is when it's in the foreground but not getting events, like when Control Center is pulled down. Background means it can still run code for a short time, and suspended means it's in memory but not running at all. Since scenes came in, the scene delegate handles each window's foreground and background changes, and the app delegate handles process-level things like launch setup and push registration. In SwiftUI I read scenePhase from the environment. When the app goes to the background, I save drafts and state, pause timers and media, and if I need to finish a short task like an upload, I ask for extra background time. I never rely on the will-terminate callback, because the system can end a suspended app without calling anything."
Saving critical data only in applicationWillTerminate, or not knowing the difference between background and suspended.
Data: check analytics for how many active users are on each iOS version, especially paying users.
Options: use availability checks with a simpler fallback, build it for everyone the older way, or raise the minimum version later.
Cost: name the cost of keeping two code paths and agree when to remove the old one.
"I'd start with our own analytics, because the answer depends on how many active users, and especially paying users, are on older versions. If most people are on the new version, I'd build the feature with the new API behind an availability check, and give older versions either a simpler fallback or no feature at all with nothing broken. If a big group is still on older versions, I'd look at whether the feature can be built with older APIs at an acceptable cost. I'd be honest with the product owner that two code paths means more testing and more bugs, so I'd put a date on dropping the old path, usually when we next raise our minimum iOS version. Whatever we pick, I'd test on a real device running the oldest version we support."
if #available(iOS 17, *) {
showNewExperience()
} else {
showClassicExperience()
}
Either refusing the feature outright or raising the minimum version without looking at who would lose access.
#available and @available?Inject: depend on a protocol for the API, passed in through the initializer, not a hard-coded singleton.
Fake: a small stub returns success or a chosen error, so tests are fast and never hit the network.
Test behaviour: loading, success, empty and error states, and what the user would see in each.
"The main thing is not letting the view model reach out to a singleton network client. I define a small protocol for what it needs, like fetch profile, and pass it in through the initializer. In the app I pass the real client, and in tests a stub that returns whatever I tell it to, a success or a specific error. Then I test the states a user would see: that it shows loading, that on success the title is set, that an empty list shows the empty message, and that an error shows a retry option instead of crashing. Tests can be async, so I just await the load call and assert. I keep UI tests for a few critical flows like sign-in and checkout, because they're slower and more fragile than unit tests."
import XCTest
@testable import MyApp
// ProfileAPI is a protocol in the app target
struct StubAPI: ProfileAPI {
let result: Result<String, Error>
func fetchName() async throws -> String { try result.get() }
}
final class ProfileViewModelTests: XCTestCase {
@MainActor
func testShowsNameOnSuccess() async {
let model = ProfileViewModel(api: StubAPI(result: .success("Asha")))
await model.load()
XCTAssertEqual(model.title, "Asha")
}
}
Saying the app is too UI-heavy to test, or writing tests that call the real server and fail whenever the network is slow.
Signal: where the crash showed up, such as the Xcode Organizer or your crash reporting tool, and how many users it hit.
Diagnosis: the symbolicated stack trace, the pattern in devices or steps, and how you reproduced it.
Fix and prevention: the fix plus a test, a guard or a process change so the same kind of bug cannot slip through.
"After one release at my last company, our crash reporting tool showed a new top crash: index out of range inside a table view data source. It only happened on the orders screen, mostly when people pulled to refresh while a background update was coming in. The stack trace was symbolicated, so I could see the exact line. The cause was that a network callback changed the orders array on a background thread while the table was reloading on the main thread, so the count and the rows didn't match. I moved all updates to the list onto the main actor and switched the screen to a diffable data source, which applies changes as one snapshot. I added a test that fires refreshes from several tasks at once, and I turned on Thread Sanitizer in one of our CI test runs so data races like this get caught earlier."
Wrapping the crashing line in a check that hides the symptom without understanding why the data was wrong.
Read carefully: understand the exact guideline and what the reviewer saw; it may be a misunderstanding.
Respond or fix: reply with notes or a demo account if they could not test, or make the smallest fix and resubmit.
Communicate: tell the launch owner the risk now; ask for an expedited review if the deadline truly matters.
"First I'd read the rejection message closely and look at any screenshots, because a lot of rejections are the reviewer not being able to get in or not finding a feature. If that's the case, I reply in App Store Connect with a working demo account and clear notes on where to find things. If it's a real issue, like a permission prompt without a clear purpose string or a missing way to delete an account when we let people create one, I make the smallest fix that addresses it and resubmit. If the deadline really matters, I can request an expedited review, though that's never guaranteed. In parallel I tell the product owner straight away, with a realistic view of the risk, so marketing can plan. Going forward I'd submit earlier and set the release to manual so approval and launch day are separate."
Arguing angrily with the reviewer, hiding the rejection from the launch owner, or quietly removing features to slip past review.
Stop the spread: pause the phased release if one is running; turn off the feature with a remote flag if possible.
Diagnose fast: group crashes by stack, OS and device; find the change that caused it.
Fix and follow up: ship a small hotfix, ask for an expedited review, tell support and stakeholders, then do a blameless review.
"My first goal is to stop more people getting the bad build. If we used a phased release, I'd pause it straight away, which limits new automatic updates. If the crashing code is behind a remote flag, I'd switch it off, which can fix things for users who already updated, without waiting for review. Then I'd look at the crash reports grouped by stack trace, OS version and device, and compare with what changed in this release. Once I know the cause, I'd ship the smallest possible fix, not a bundle of other changes, and request an expedited review. I'd keep support and the product owner updated so they can answer users. Afterwards we'd do a blameless review and ask why this wasn't caught, and whether risky features should always ship behind a flag."
Assuming you can revert to the previous version like a web deploy, or bundling the fix with unrelated features.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.