iOS Interview Questions and Answers

Last updated:

Check out 45 of the most common iOS interview questions, then take an AI-powered practice interview

SwiftSwiftUIUIKitCore DataXcode
45+
Questions
18
Basic
18
Intermediate
9
Advanced
Q1

Why are Array, String and Dictionary structs in Swift, and how does copy-on-write keep that cheap?

BasicSwift Language

Answer

Array, String, Dictionary and Set are value types, so assigning one to another logically produces an independent copy. Doing that literally on a ten thousand element array would be ruinous, so the standard library implements copy-on-write. The struct holds a reference to a heap buffer; when you mutate it, the implementation calls isKnownUniquelyReferenced on that buffer and only allocates a fresh copy if the reference count is greater than one.

Passing an array into ten functions therefore costs ten retains, not ten allocations, and the copy materialises only when somebody writes. Interviewers probe two consequences. First, an accidental extra reference (the array captured in a long-lived closure, stored on a class property, or bridged to NSArray) turns every subsequent mutation into an O(n) copy, which is a very common reason a feed screen suddenly starts dropping frames after a refactor that looked harmless.

Second, value semantics are what make SwiftUI diffing and Swift 6 concurrency checking work: a struct whose stored properties are all value types is implicitly Sendable and can cross an actor boundary safely. The natural follow-up is when you should reach for a class instead, and the honest answer is identity (two places must observe the same instance), subclassing an Objective-C type, or needing deinit for resource cleanup. Senior candidates are often asked to sketch a custom copy-on-write wrapper, which is a private final class box plus isKnownUniquelyReferenced.

import Foundation

final class Storage {
    var values: [Int]
    init(_ values: [Int]) { self.values = values }
}

struct IntBuffer {
    private var storage: Storage
    init(_ values: [Int]) { storage = Storage(values) }

    var values: [Int] { storage.values }

    mutating func append(_ value: Int) {
        if !isKnownUniquelyReferenced(&storage) {
            storage = Storage(storage.values) // real copy only when shared
        }
        storage.values.append(value)
    }
}

var a = IntBuffer([1, 2, 3])
var b = a          // no allocation yet, buffer is shared
b.append(4)        // uniqueness check fails, copy happens here
print(a.values)    // [1, 2, 3]
print(b.values)    // [1, 2, 3, 4]

Key Points

  • Collections are structs with value semantics, backed by a shared heap buffer
  • isKnownUniquelyReferenced drives the copy decision on mutation
  • A stray extra reference makes every mutation O(n)
  • Value semantics are the basis of implicit Sendable conformance in Swift 6
Q2

What exactly happens when you force-unwrap a nil optional, and what should you write instead?

BasicOptionals

Answer

Force unwrapping compiles to a runtime check that calls fatalError when the value is none. In a crash report this surfaces as a SIGTRAP / EXC_BREAKPOINT with the message 'Unexpectedly found nil while unwrapping an Optional value', and the symbolicated frame points at your line, not at a library. It is not an exception you can catch: the process is gone.

The three places nil sneaks in are implicitly unwrapped optionals from Objective-C headers that were never audited for nullability, @IBOutlet properties touched before the view hierarchy loads, and dictionary or JSON lookups where the key is optional in practice even if the backend team swears it is not. The replacements the interviewer wants to hear are guard let for the early-exit path, if let (the shorthand form if let user, without repeating the name, since Swift 5.7), optional chaining with ?. when the whole expression can reasonably be nil, and ?? for a defensible default. The important nuance is that guard let is not just style: it keeps the unwrapped value in scope for the rest of the function and forces you to write the failure branch, which is where good candidates add a log or an analytics event instead of silently returning.

Force unwrap is defensible in exactly two cases: values guaranteed by the build itself, such as UIImage(named:) for an asset you ship, and unit tests, where a crash is a legitimate test failure. Anywhere else, treat it as an unhandled error path in code review.

struct Profile { let name: String; let city: String? }

func greeting(for profile: Profile?) -> String {
    // Early exit keeps the happy path unindented
    guard let profile else { return String(localized: "Sign in to continue") }

    // Optional chaining plus a defensible default
    let city = profile.city?.trimmingCharacters(in: .whitespaces) ?? "India"
    return "Hi \(profile.name), showing jobs in \(city)"
}

// Fails fast in tests, never in shipped app code
func loadIcon() -> UIImage {
    UIImage(named: "app-icon")!  // asset is compiled into the bundle
}
💡 Pro Tip: In code review, treat every ! outside tests as an unwritten error branch. Ask what should happen when it is nil, then write that branch.
Q3

How does ARC decide when to deallocate an object, and when do you use weak versus unowned?

BasicMemory Management

Answer

ARC is a compile-time transformation, not a garbage collector. The compiler inserts retain and release calls around every strong reference, and when an object's strong count hits zero it runs deinit and frees the memory immediately and deterministically. There is no background sweep, which is why iOS memory profiles are so predictable compared with the JVM.

The failure mode is a reference cycle: two objects hold each other strongly, both counts stay at one, and neither deinit ever runs. Classic examples are a view controller holding a closure that captures self, a child object holding a strong delegate back-pointer, a Timer that retains its target, and a Combine cancellable stored on the same object it feeds. The fixes are weak, which zeroes to nil when the target dies and therefore must be optional, and unowned, which does not zero and traps with EXC_BAD_ACCESS if you touch it after deallocation.

Use weak when the referenced object can legitimately outlive or predecease you, which covers delegates and almost every escaping closure that captures self. Use unowned only when the lifetime relationship is guaranteed by construction, such as a child that can never outlive its parent, because unowned skips the side-table lookup and is marginally faster. Interviewers usually follow up with how you prove a leak exists, and the answer is the Debug Memory Graph button in Xcode with malloc stack logging enabled, or the Leaks and Allocations instruments. Add a print or a breakpoint in deinit for the specific screen: if popping a view controller does not fire deinit, you have a cycle.

final class FeedViewModel {
    private var task: Task<Void, Never>?
    var onUpdate: (([String]) -> Void)?

    func start() {
        // [weak self] breaks the closure -> self -> task -> closure cycle
        task = Task { [weak self] in
            guard let self else { return }
            let items = await self.fetch()
            await MainActor.run { self.onUpdate?(items) }
        }
    }

    func fetch() async -> [String] { [] }

    deinit {
        task?.cancel()
        print("FeedViewModel deinit") // no print on pop means a cycle
    }
}

protocol FeedDelegate: AnyObject {}
final class Feed {
    weak var delegate: FeedDelegate?  // must be weak, must be class-bound
}

Key Points

  • ARC is compile-time retain/release, deallocation is deterministic
  • weak zeroes to nil and must be Optional; unowned traps after dealloc
  • Delegates are weak; escaping closures capturing self usually need [weak self]
  • Prove leaks with the Memory Graph Debugger and a print in deinit
Q4

Walk through the UIViewController lifecycle and say where layout-dependent code belongs.

BasicUIKit

Answer

The order is init (coder or nibName), loadView, viewDidLoad, viewWillAppear, viewIsAppearing, viewWillLayoutSubviews, viewDidLayoutSubviews, viewDidAppear, then on the way out viewWillDisappear, viewDidDisappear, and finally deinit. viewDidLoad runs exactly once per instance and is the place for one-time wiring: adding subviews, registering cells, creating constraints, subscribing to notifications. viewWillAppear runs every time the controller is about to become visible, including when you pop back to it, so it is where you refresh data that may be stale and where you resume anything you paused. The trap that catches most candidates is reading view.bounds inside viewDidLoad: at that point the view is still at whatever size the nib or storyboard baked in, so any frame math or circular-avatar corner radius computed there is wrong on some devices. viewIsAppearing, added in iOS 17 and backported to iOS 13 when you build with a recent Xcode, is the correct hook for that work because the trait collection and the view geometry are both final by then, and unlike viewDidLayoutSubviews it runs once per appearance rather than on every layout pass. viewDidLayoutSubviews can fire many times, including on rotation, keyboard show and safe area changes, so anything you do there must be idempotent and cheap. Never start a network request from viewDidLayoutSubviews. Finally, remember that a view controller pushed and popped repeatedly is a fresh instance each time in most navigation flows, so state you cache on the controller is lost, whereas state on the coordinator or view model survives.

final class ProfileViewController: UIViewController {
    private let avatar = UIImageView()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(avatar)      // one-time wiring only
        avatar.clipsToBounds = true
    }

    override func viewIsAppearing(_ animated: Bool) {
        super.viewIsAppearing(animated)
        // Geometry and traits are final here, unlike viewDidLoad
        avatar.layer.cornerRadius = avatar.bounds.width / 2
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        viewModel.refreshIfStale()   // runs on every return to this screen
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        // Fires repeatedly: keep it cheap and idempotent
    }
}
💡 Pro Tip: If a corner radius or gradient frame looks correct on your simulator but wrong on a Pro Max, you almost certainly computed it in viewDidLoad instead of viewIsAppearing.
Q5

What is the difference between @State, @Binding, @StateObject and @ObservedObject in SwiftUI?

BasicSwiftUI

Answer

@State declares view-local storage for a value type. SwiftUI keeps it outside the struct, keyed to the view's identity, so it survives the struct being recreated on every render. Mark it private, because it is owned by exactly one view. @Binding is a two-way reference to state owned elsewhere; you create one with the dollar prefix on the owner side and receive it as @Binding in the child.

That is how a TextField in a child view writes back into the parent's state without the child owning anything. @StateObject and @ObservedObject both wrap a reference type conforming to ObservableObject, and the difference is ownership of the lifecycle. @StateObject initialises the object exactly once for the lifetime of that view identity, whereas @ObservedObject just holds a reference and does not manage creation. The bug this causes in production is subtle: if a parent re-renders and you wrote @ObservedObject var vm = FeedViewModel(), a new view model is constructed on each render, in-flight requests are orphaned, and the user watches the spinner restart. The rule is create with @StateObject, pass down with @ObservedObject.

On iOS 17 and later the Observation framework changes this picture: mark your model @Observable, hold it with plain @State in the owning view, pass it as a plain let to children, and use @Bindable when a child needs a binding into it. Observation also tracks reads at the property level, so a view that only reads model.title does not re-render when model.items changes, which is a real performance win over ObservableObject and objectWillChange broadcasting to everyone.

import SwiftUI
import Observation

@Observable
final class SearchModel {
    var query = ""
    var results: [String] = []
}

struct SearchScreen: View {
    // Owner creates it once with @State (Observation, iOS 17+)
    @State private var model = SearchModel()

    var body: some View {
        VStack {
            SearchField(model: model)
            List(model.results, id: \.self, rowContent: Text.init)
        }
    }
}

struct SearchField: View {
    // Plain reference, @Bindable gives us $model.query
    @Bindable var model: SearchModel

    var body: some View {
        TextField("Search jobs", text: $model.query)
            .textFieldStyle(.roundedBorder)
    }
}

Key Points

  • @State owns value-type storage tied to view identity
  • @Binding is a two-way reference to state owned by a parent
  • @StateObject creates once; @ObservedObject only observes and can be recreated
  • @Observable plus @State plus @Bindable is the iOS 17+ replacement, with per-property read tracking
Q6

How does Auto Layout resolve conflicts, and what do content hugging and compression resistance actually control?

BasicLayout

Answer

Auto Layout solves a system of linear equations. Every constraint has a priority from 1 to 1000, where 1000 is required, .defaultHigh is 750 and .defaultLow is 250. The engine must satisfy every required constraint; if it cannot, it breaks one and logs 'Unable to simultaneously satisfy constraints' along with the list it considered and the one it dropped.

Optional constraints below 1000 are treated as goals the solver gets as close to as it can. Views that can size themselves, such as UILabel, UIButton and UIImageView, expose an intrinsicContentSize, and the solver converts that into two implicit constraints per axis, controlled by content hugging priority and content compression resistance priority. Hugging is the resistance to growing larger than the intrinsic size; compression resistance is the resistance to being squeezed smaller.

So when two labels sit side by side and you want the second one to truncate rather than the first, you raise the first label's compression resistance. When a label stretches instead of staying tight against its text, you raise its hugging priority. Practical rules interviewers listen for: set translatesAutoresizingMaskIntoConstraints to false on every view you constrain in code, activate constraints with NSLayoutConstraint.activate for one batch pass rather than setting isActive individually, and prefer layout guides and UIStackView over hand-rolled spacer views. For debugging, hasAmbiguousLayout tells you a view is under-constrained, the view debugger shows the ambiguity visually, and systemLayoutSizeFitting is how you ask a cell for its computed height when you need it outside the normal pass.

let title = UILabel()
let badge = UILabel()
[title, badge].forEach {
    $0.translatesAutoresizingMaskIntoConstraints = false
    view.addSubview($0)
}

// Badge must never truncate; the title gives way instead
badge.setContentCompressionResistancePriority(.required, for: .horizontal)
badge.setContentHuggingPriority(.required, for: .horizontal)
title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)

NSLayoutConstraint.activate([
    title.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
    title.centerYAnchor.constraint(equalTo: view.centerYAnchor),
    badge.leadingAnchor.constraint(equalTo: title.trailingAnchor, constant: 8),
    badge.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
    badge.centerYAnchor.constraint(equalTo: title.centerYAnchor),
])

Key Points

  • Priorities run 1 to 1000; required is 1000, defaultHigh 750, defaultLow 250
  • Hugging resists growing, compression resistance resists shrinking
  • Batch with NSLayoutConstraint.activate, always disable autoresizing translation
  • Read the broken-constraint log: it names the exact constraint that was dropped
Q7

How does UITableView cell reuse work, and what production bugs come from getting it wrong?

BasicUIKit

Answer

A table view keeps a small pool of cells, roughly the number visible plus a couple, and recycles them as you scroll. dequeueReusableCell(withIdentifier:for:) either returns a recycled instance or creates one from the registered class or nib. Because instances are recycled, every cell you hand back must be fully configured for the new index path, not partially. The two bugs this causes are ubiquitous in Indian consumer apps and interviewers ask about both.

First, stale state: you set a badge or a strikethrough in one row, scroll, and it reappears on an unrelated row. The fix is prepareForReuse to reset mutable UI plus unconditional configuration in cellForRowAt, never an if that only sets a property in one branch. Second, the async image race: you start an image download for row 3, the cell is recycled for row 12 before the download finishes, and the wrong photo appears.

The fixes are to cancel the in-flight task in prepareForReuse, or to stamp the cell with the identifier it is loading and discard results that no longer match. Beyond correctness, reuse is why scroll performance holds up: never build view hierarchies inside cellForRowAt, never call sizeToFit on a deep hierarchy there, and use UITableView.automaticDimension with real vertical constraints instead of computing heights by hand. Modern code should prefer UICollectionView with a list configuration and a diffable data source, but every legacy screen you inherit in India runs on this API, so interviewers still test it.

final class JobCell: UITableViewCell {
    static let reuseID = "JobCell"
    private var imageTask: Task<Void, Never>?
    private let thumb = UIImageView()

    func configure(with job: Job) {
        textLabel?.text = job.title
        thumb.image = nil
        imageTask = Task { [weak self] in
            let image = await ImageLoader.shared.load(job.logoURL)
            guard !Task.isCancelled else { return }
            self?.thumb.image = image
        }
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        imageTask?.cancel()   // kills the wrong-image race
        imageTask = nil
        thumb.image = nil
        accessoryType = .none // reset every mutable bit of UI
    }
}
💡 Pro Tip: If a reviewer sees an if without an else inside cellForRowAt, that is almost always a recycled-state bug waiting to be filed by QA.
Q8

How do UIApplicationDelegate and UISceneDelegate split responsibilities, and what app states exist?

BasicApp Lifecycle

Answer

Since multi-window support arrived, the process and the UI have separate lifecycles. UIApplicationDelegate owns process-level events: application(_:didFinishLaunchingWithOptions:) for one-time bootstrap, registration for remote notifications and the resulting device token, handling background URLSession completion, and configuring scene sessions. UISceneDelegate owns a single window's lifecycle: scene(_:willConnectTo:options:) builds the window and root view controller, sceneDidBecomeActive and sceneWillResignActive bracket the foreground-active state, sceneDidEnterBackground and sceneWillEnterForeground bracket backgrounding, and sceneDidDisconnect tears the UI down while the process may keep running.

The states are not-running, inactive (foreground but not receiving events, for example during an incoming call or in the app switcher), active, background (running briefly with limited time) and suspended (in memory but frozen, and the first candidate for termination under memory pressure). Two production details matter. The system watchdog kills an app that spends too long in didFinishLaunchingWithOptions, and the resulting crash report carries exception code 0x8badf00d, so heavy SDK initialisation belongs off the launch path or behind a first-use trigger.

And save user-visible state on sceneDidEnterBackground rather than in deinit or applicationWillTerminate, because a suspended app is usually killed without any further callback. In a SwiftUI app you often use neither delegate directly: the App struct plus the scenePhase environment value gives you active, inactive and background, and you bridge to UIApplicationDelegate with @UIApplicationDelegateAdaptor when you need push tokens or other process-level hooks.

import SwiftUI

@main
struct JobsApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup { RootView() }
            .onChange(of: scenePhase) { _, phase in
                switch phase {
                case .background: Persistence.shared.saveNow()
                case .active:     Analytics.resume()
                case .inactive:   break
                @unknown default: break
                }
            }
    }
}

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ app: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
        PushService.upload(token: token.map { String(format: "%02x", $0) }.joined())
    }
}

Key Points

  • UIApplicationDelegate = process events; UISceneDelegate = one window's lifecycle
  • States: not-running, inactive, active, background, suspended
  • 0x8badf00d in a crash report means the watchdog killed a slow launch
  • Persist on sceneDidEnterBackground; suspended apps are killed without warning
Q9

Which Info.plist entries block an App Store submission, and what goes into PrivacyInfo.xcprivacy?

BasicApp Store & Privacy

Answer

Two separate files matter. Info.plist needs a purpose string for every protected resource you touch, or the app crashes the instant you request permission: NSCameraUsageDescription, NSPhotoLibraryUsageDescription and NSPhotoLibraryAddUsageDescription, NSMicrophoneUsageDescription, NSLocationWhenInUseUsageDescription and NSLocationAlwaysAndWhenInUseUsageDescription, NSContactsUsageDescription, NSFaceIDUsageDescription, and NSUserTrackingUsageDescription if you call App Tracking Transparency. Reviewers reject vague strings, so write what you do with the data, not 'we need camera access'.

Also there are UIBackgroundModes for background capabilities, NSAppTransportSecurity if you still have a plaintext endpoint (expect to justify it), and ITSAppUsesNonExemptEncryption to skip the export compliance prompt on every upload. The second file is the privacy manifest, PrivacyInfo.xcprivacy, which Apple has enforced since 2024. It declares NSPrivacyTracking, tracking domains, the data types you collect, and critically NSPrivacyAccessedAPITypes: the required-reason APIs plus the reason code for each.

Common ones are UserDefaults with reason CA92.1, file timestamp APIs with C617.1, system boot time with 35F9.1 and disk space with E174.1. If you or any bundled SDK uses one without a declared reason, App Store Connect emails you an ITMS-91053 warning and eventually rejects the build. Third-party SDKs on Apple's commonly-used list must ship their own manifest and a valid code signature, which in practice means keeping analytics and crash SDKs current. A good answer ends with the workflow: generate an aggregate privacy report from the Xcode archive so you can see what your dependencies declared, and diff it before each release.

💡 Pro Tip: ITMS-91053 emails after upload are almost always a dependency, not your code. Generate the privacy report from the archive to find which pod or package declared the API.
Q10

How do protocol extensions differ from subclassing, and where does static dispatch surprise people?

BasicSwift Language

Answer

A protocol extension adds a default implementation that every conforming type inherits without any class hierarchy, which is how Swift gives structs and enums shared behaviour. The subtlety interviewers love is dispatch. If a method is declared as a protocol requirement and also given a default in the extension, calls go through the protocol witness table, so a conforming type's own implementation wins even when the value is held as the protocol type.

If the method exists only in the extension and is not a requirement, the call is statically dispatched on the compile-time type, so holding the value as the protocol gives you the extension version and holding it as the concrete type gives you the type's own version. Same object, two different results, which is exactly the trick question. The rule is simple: if you intend a type to be able to override something, declare it in the protocol body, not only in the extension.

Related 2026 details: Swift 6 requires the any keyword on existentials, so any Shape rather than bare Shape, which makes the boxing explicit and reminds you it has a cost. Prefer some Shape (an opaque type, resolved at compile time, no box, full specialisation) for parameters and return types where a single concrete type flows through. Primary associated types let you constrain existentials usefully, as in any Collection<String>. And protocol extensions are the standard way to make a large codebase testable without inheritance, because a protocol with a default implementation can be satisfied by a lightweight fake in a test target.

protocol Analytics {
    func track(_ event: String)      // requirement: dynamic dispatch
}

extension Analytics {
    func track(_ event: String) { print("default \(event)") }
    func flush() { print("default flush") }  // NOT a requirement
}

struct Firebase: Analytics {
    func track(_ event: String) { print("firebase \(event)") }
    func flush() { print("firebase flush") }
}

let concrete = Firebase()
let existential: any Analytics = Firebase()

concrete.track("open")      // firebase open
existential.track("open")   // firebase open  (witness table)
concrete.flush()            // firebase flush
existential.flush()         // default flush  (static dispatch!)

Key Points

  • Requirements dispatch dynamically; extension-only members dispatch statically
  • Declare anything overridable in the protocol body, not just the extension
  • Swift 6 requires explicit any for existentials; prefer some where possible
  • Protocol plus default implementation is the standard seam for test fakes
Q11

Explain Swift error handling: throws, try?, try!, typed throws and when Result is still useful.

BasicError Handling

Answer

A function marked throws can propagate an error, and callers must handle it with do-catch or propagate with try. try? converts a throw into nil, discarding the reason, which is fine for genuinely optional work and terrible for anything a user will complain about. try! traps on error and crashes the process, so it belongs in tests and in cases the build guarantees. rethrows means the function only throws if the closure you passed throws, which is how map and filter stay non-throwing for non-throwing closures. Swift 6 added typed throws, written as throws(NetworkError), which puts the concrete error type in the signature so the compiler can check your catch clauses exhaustively; it is most useful for tightly scoped modules and embedded code, while general application boundaries usually stay with untyped throws so you can add error cases without breaking every caller. defer runs on the way out regardless of path, which is where you close file handles and end background tasks. Result still earns its place in three spots: bridging old completion-handler APIs, storing an outcome in a property or an enum-driven view state so the UI can render success and failure without a separate flag, and sending outcomes through a stream where a thrown error would terminate the sequence. The production detail interviewers probe is cancellation: with async/await, a cancelled task surfaces as CancellationError from Task.checkCancellation, and URLSession reports URLError.cancelled, so a catch-all that logs every error as a failure will pollute your dashboards with noise from screens the user simply left.

enum NetworkError: Error { case offline, badStatus(Int), decoding(any Error) }

func loadJobs() async throws(NetworkError) -> [Job] {
    do {
        let (data, response) = try await URLSession.shared.data(from: .jobsFeed)
        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
            throw NetworkError.badStatus((response as? HTTPURLResponse)?.statusCode ?? -1)
        }
        return try JSONDecoder().decode([Job].self, from: data)
    } catch let error as URLError where error.code == .notConnectedToInternet {
        throw NetworkError.offline
    } catch let error as DecodingError {
        throw NetworkError.decoding(error)
    } catch is CancellationError {
        throw NetworkError.offline  // user left the screen, do not alert
    } catch {
        throw NetworkError.decoding(error)
    }
}

Key Points

  • try? discards the reason, try! crashes; both need justification in review
  • typed throws (Swift 6) makes catch exhaustive for tightly scoped errors
  • defer for cleanup, rethrows for closure-forwarding APIs
  • Filter CancellationError and URLError.cancelled out of error dashboards
Q12

How do you decode a messy JSON payload with Codable when keys are snake_case and types are inconsistent?

BasicNetworking

Answer

Start with the cheap wins. JSONDecoder().keyDecodingStrategy = .convertFromSnakeCase maps created_at to createdAt for the whole payload, so you only write a CodingKeys enum for the properties that genuinely differ. dateDecodingStrategy = .iso8601 handles standard timestamps, but note that the built-in ISO8601 strategy rejects fractional seconds, which is the single most common decoding failure with Indian backends that emit 2026-08-11T10:15:30.123Z; use .custom with an ISO8601DateFormatter configured with withFractionalSeconds, or .formatted with a fixed en_US_POSIX DateFormatter. For a field that arrives sometimes as a number and sometimes as a string, write init(from decoder:) for that model and try both container decodes in order.

For arrays where one bad element should not kill the whole response, decode into an UnkeyedDecodingContainer and skip failures, or wrap each element in a small FailableDecodable. The debugging habit interviewers want to hear is reading the DecodingError properly: keyNotFound, typeMismatch, valueNotFound and dataCorrupted each carry a Context with a codingPath, and printing that path tells you the exact field and index rather than a generic 'the JSON is wrong'. Also decode into a wire model and map to your domain model rather than making one struct serve both, because then a backend rename is a one-line change in the mapping layer instead of a refactor across the app. Finally, set the decoder up once and inject it; creating a JSONDecoder per response is wasteful and makes strategies drift between call sites.

struct Job: Decodable {
    let id: String
    let title: String
    let salaryLPA: Double?
    let postedAt: Date

    enum CodingKeys: String, CodingKey {
        case id, title, postedAt
        case salaryLPA = "salary_lpa"   // wins over the global strategy
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        title = try c.decode(String.self, forKey: .title)
        postedAt = try c.decode(Date.self, forKey: .postedAt)
        // Backend sends 12 or "12" depending on the endpoint
        if let n = try? c.decode(Double.self, forKey: .salaryLPA) { salaryLPA = n }
        else { salaryLPA = Double(try c.decodeIfPresent(String.self, forKey: .salaryLPA) ?? "") }
    }
}

let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .custom { d in
    let s = try d.singleValueContainer().decode(String.self)
    guard let date = iso.date(from: s) else { throw DecodingError.dataCorruptedError(in: try d.singleValueContainer(), debugDescription: "bad date \(s)") }
    return date
}
💡 Pro Tip: When a decode fails in production, log error.context.codingPath joined by dots. It points at the exact field, which turns a two-hour bug into a two-minute one.
Q13

How do you make a request with URLSession and async/await, and how does cancellation actually propagate?

BasicNetworking

Answer

The modern API is try await URLSession.shared.data(for: request), which returns a tuple of Data and URLResponse and throws on transport failure. Note that a 404 or a 500 is not a thrown error: URLSession considers the request successful, so you must cast the response to HTTPURLResponse and check statusCode yourself. That single omission is the most common bug in take-home submissions.

Configure timeouts on URLSessionConfiguration with timeoutIntervalForRequest (per request activity) and timeoutIntervalForResource (total lifetime including retries), because the defaults of 60 seconds and 7 days are wrong for a mobile screen on a patchy 4G connection. Cancellation is structured: if the Task that owns the await is cancelled, the underlying data task is cancelled too, and the await throws either CancellationError or URLError with code .cancelled. This matters in SwiftUI, where .task { } automatically cancels when the view disappears, so a user who swipes back mid-request does not hold the connection open.

If you launch work with Task { } inside a view model, you own the cancellation: store the handle and cancel it in deinit or when a new request supersedes it. Interviewers also probe caching. URLSession has a URLCache by default and honours server cache headers, so a well-behaved backend gives you free conditional requests with ETag and If-None-Match; if your API sends no cache headers, set cachePolicy explicitly rather than assuming. For uploads and large downloads use uploadTask and downloadTask with a background configuration so the transfer survives the app being suspended.

actor JobsAPI {
    private let session: URLSession = {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 15
        config.timeoutIntervalForResource = 60
        config.waitsForConnectivity = true
        return URLSession(configuration: config)
    }()

    func fetch(page: Int) async throws -> [Job] {
        var request = URLRequest(url: URL(string: "https://api.example.com/jobs?page=\(page)")!)
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) }
        guard (200..<300).contains(http.statusCode) else {
            throw URLError(.badServerResponse) // 404/500 do NOT throw on their own
        }
        try Task.checkCancellation()
        return try JSONDecoder().decode([Job].self, from: data)
    }
}

// SwiftUI cancels this automatically when the view goes away
.task { jobs = (try? await api.fetch(page: 1)) ?? [] }

Key Points

  • HTTP error status codes do not throw; check HTTPURLResponse.statusCode
  • Set timeoutIntervalForRequest and forResource; the defaults are unusable on mobile
  • Task cancellation cancels the underlying data task and throws URLError.cancelled
  • SwiftUI .task cancels on disappear; Task { } in a view model does not
Q14

Where should an auth token live: UserDefaults, Keychain, or a file in Documents?

BasicSecurity

Answer

Keychain, always, for anything that grants access. UserDefaults is a plist inside the app container: it is unencrypted at rest beyond the device-level file protection, it is included in unencrypted iTunes and Finder backups, and anyone with a jailbroken device or an extracted backup can read it in seconds. It is fine for a theme preference or an onboarding flag, and nothing else.

The Documents directory is worse in one specific way: it is what iCloud backs up and, if you set UIFileSharingEnabled, what the user can browse over USB. Keychain items are encrypted by the Secure Enclave-backed keys, survive app deletion by default (which surprises people and is sometimes desirable, sometimes a bug you must handle at first launch), and let you set an accessibility class. Use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly for a refresh token that background tasks need: it is available after the first unlock following a reboot but never leaves the device in a backup.

Use kSecAttrAccessibleWhenUnlockedThisDeviceOnly for values only needed while the user is present. For high-value actions, gate retrieval with LocalAuthentication and a SecAccessControl flagged .biometryCurrentSet, so enrolling a new fingerprint or face invalidates the item. Two practical notes for interviews at Indian fintech: store only the token, never the PIN or card data, and add a keychain-clearing step on first launch after install (detect with a UserDefaults flag) so a reinstall does not silently resurrect a session belonging to a previous user of the device.

import Security

enum TokenStore {
    private static let account = "refresh_token"

    static func save(_ token: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
            kSecValueData as String: Data(token.utf8),
        ]
        SecItemDelete(query as CFDictionary)           // upsert semantics
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError.status(status) }
    }

    static func read() -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
        ]
        var item: CFTypeRef?
        guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
              let data = item as? Data else { return nil }
        return String(decoding: data, as: UTF8.self)
    }
}

enum KeychainError: Error { case status(OSStatus) }

Key Points

  • UserDefaults is an unencrypted plist included in backups: preferences only
  • Keychain survives app deletion, so clear it on first launch after install
  • Pick the accessibility class deliberately, ThisDeviceOnly keeps it out of backups
  • Gate high-value items with SecAccessControl and .biometryCurrentSet
Q15

When would you still use DispatchQueue instead of Task and @MainActor?

BasicConcurrency

Answer

Grand Central Dispatch and Swift Concurrency solve overlapping problems with very different models. DispatchQueue.main.async schedules a block on the main run loop; DispatchQueue.global(qos:) hands work to a thread from a pool sized by the system; DispatchQueue(label:) creates a serial queue you can use as a lock substitute. Swift Concurrency instead gives you cooperative tasks running on a fixed-width pool, with await as an explicit suspension point and actors for mutual exclusion.

In new code, prefer Task and @MainActor: the compiler can then reason about isolation and catch data races at build time, which GCD cannot do at all. GCD still earns its place in four situations. DispatchQueue.main.asyncAfter for a simple UI delay when you do not want a Task and its cancellation semantics.

DispatchSource timers and file-system watchers, which have no direct async equivalent. Bridging into Objective-C or C libraries that hand you a callback on an arbitrary thread. And DispatchQueue.concurrentPerform for genuinely CPU-parallel work over an index range, where the task pool would just add overhead.

The rule interviewers want stated is never block a cooperative thread: calling DispatchSemaphore.wait or sleep inside an async function can deadlock, because the concurrency pool has roughly one thread per core and blocking them starves every other task. If you must bridge a callback API, use withCheckedContinuation rather than a semaphore, and remember a continuation must be resumed exactly once, resuming twice traps and never resuming leaks the task forever.

// Bridging a callback API without blocking a cooperative thread
func currentLocation() async throws -> CLLocation {
    try await withCheckedThrowingContinuation { continuation in
        locationManager.requestLocation { result in
            switch result {
            case .success(let location): continuation.resume(returning: location)
            case .failure(let error):    continuation.resume(throwing: error)
            }
        }
    }
}

// Wrong: blocks a pool thread and can deadlock the whole app
func badBridge() async -> CLLocation {
    let semaphore = DispatchSemaphore(value: 0)
    var result: CLLocation!
    locationManager.requestLocation { result = try? $0.get(); semaphore.signal() }
    semaphore.wait()   // never do this inside async code
    return result
}
💡 Pro Tip: withCheckedContinuation is checked at runtime: double-resume traps with a clear message, and Xcode logs a warning if a continuation leaks without resuming.
Q16

What is the difference between a target, a scheme and a build configuration in Xcode?

BasicXcode & Tooling

Answer

A target produces one product: an app, an app extension, a framework, a unit test bundle. It owns its own build settings, source file membership, Info.plist, entitlements and dependencies. A build configuration is a named set of build setting values, Debug and Release by default, and most teams add Staging or QA so they can point at a different API host and use a different bundle identifier.

A scheme is what the Run, Test, Profile, Analyze and Archive buttons actually execute: it maps each action to a target plus a configuration plus arguments, environment variables, launch options and test plans. So a typical setup is one app target, three configurations (Debug, Staging, Release), and three schemes so a QA build is a single dropdown change rather than editing settings by hand. Two things separate people who have shipped from people who have only run projects.

First, build settings belong in xcconfig files checked into git, not in the pbxproj: xcconfig makes settings reviewable in a pull request, removes most merge conflicts in the project file, and lets you inherit with $(inherited). Second, schemes must be marked Shared, otherwise they live in xcuserdata, do not get committed, and your CI job fails with 'scheme not found' the first time someone else runs it. Related settings worth naming in an interview: PRODUCT_BUNDLE_IDENTIFIER and DEVELOPMENT_TEAM for signing, SWIFT_ACTIVE_COMPILATION_CONDITIONS for #if flags, OTHER_SWIFT_FLAGS, and ENABLE_TESTABILITY, which must be YES on the configuration your tests run against.

// Config/Staging.xcconfig
#include "Base.xcconfig"

PRODUCT_BUNDLE_IDENTIFIER = com.company.jobs.staging
PRODUCT_NAME = Jobs Staging
API_BASE_URL = api-staging.example.com
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) STAGING
CODE_SIGN_STYLE = Manual
PROVISIONING_PROFILE_SPECIFIER = Jobs Staging AdHoc

// Info.plist reads the value via $(API_BASE_URL), then in Swift:
enum AppEnvironment {
    static var baseURL: URL {
        let host = Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String ?? "api.example.com"
        return URL(string: "https://\(host)")!
    }
}

# Command line CI equivalent
xcodebuild -workspace Jobs.xcworkspace -scheme "Jobs Staging" \
  -configuration Staging -destination 'generic/platform=iOS' archive

Key Points

  • Target = one product, configuration = a set of build settings, scheme = what a button runs
  • Keep build settings in xcconfig files so they are reviewable and merge-friendly
  • Mark schemes Shared or CI cannot find them
  • ENABLE_TESTABILITY must be YES for @testable import to work
Q17

Swift Package Manager or CocoaPods in 2026: which do you pick and what breaks during migration?

BasicXcode & Tooling

Answer

New projects should default to Swift Package Manager. It is built into Xcode, needs no Ruby toolchain, does not generate a workspace or rewrite your project file, resolves versions into Package.resolved which you commit, and supports binary dependencies through xcframework binary targets. The CocoaPods maintainers announced the project entered maintenance mode in 2024, so treat any remaining pods as legacy you should be retiring.

That said, plenty of Indian codebases still run Podfiles because a vendor SDK (payments, analytics, chat) shipped pods first, and interviewers ask how you handle the mixed state. The honest answer is that you can run both: pods keep the workspace, packages are added at the project level, and you migrate one dependency at a time as vendors publish Package.swift or an xcframework. Migration pain points to name: post_install hooks in the Podfile that patched build settings for every pod target have no SPM equivalent, so those settings move to xcconfig; resource bundles resolve differently, so Bundle.main lookups inside a library must become Bundle.module; and SPM has no per-dependency build-setting override, which is why some older C-based pods stay put.

Also mention build time. Every SPM dependency is a separate module, so a large graph slows clean builds, and Xcode's package resolution can stall CI when a git host is slow. Pin exact versions for release branches, cache the SPM directory in CI, and prefer .upToNextMinor over branch-based dependencies, which make your build non-reproducible.

// Package.swift for an internal feature module
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "JobsFeature",
    platforms: [.iOS(.v17)],
    products: [
        .library(name: "JobsFeature", targets: ["JobsFeature"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-collections", .upToNextMinor(from: "1.1.0")),
    ],
    targets: [
        .target(
            name: "JobsFeature",
            dependencies: [.product(name: "Collections", package: "swift-collections")],
            resources: [.process("Resources")],
            swiftSettings: [.swiftLanguageMode(.v6)]
        ),
        .testTarget(name: "JobsFeatureTests", dependencies: ["JobsFeature"]),
    ]
)

// Inside the package, resources come from Bundle.module, not Bundle.main
let image = UIImage(named: "empty-state", in: .module, compatibleWith: nil)
Q18

What do Swift's access levels mean, and what does @testable import actually change?

BasicSwift Language

Answer

Swift has six levels. private restricts to the enclosing declaration and its extensions in the same file. fileprivate restricts to the file. internal is the default and means the whole module. package, added in Swift 5.9, means every module in the same Swift package, which is the level that finally made modularised codebases pleasant: you can share a type across your feature modules without making it part of your public API. public means other modules can use it but not subclass it or override its members. open means public plus subclassable and overridable, and applies only to classes and class members. The design rule is that a type cannot be more visible than the types in its signature, so a public function cannot return an internal struct, and the compiler will tell you so. @testable import raises internal (and package) symbols to public visibility for the importing test file, which lets you test implementation details without weakening your production API. It does not touch private or fileprivate, so if you cannot see something from a test, it is private and either you extract it or you test through the public surface. @testable requires the module to be compiled with ENABLE_TESTABILITY set to YES, which Xcode does for Debug but not Release, hence the classic failure where the test target compiles locally and fails in a Release CI job. Interviewers often follow up by asking whether reaching for @testable is a smell: the balanced answer is that it is fine for internal seams in an app target, but a library with a proper public API should be tested through that API.

// Production module
public struct JobSearch {
    public init() {}
    public func run(query: String) -> [Job] { rank(matches(for: query)) }

    internal func matches(for query: String) -> [Job] { [] }   // visible to @testable
    private func rank(_ jobs: [Job]) -> [Job] { jobs }         // never visible to tests
}

// Test target
import Testing
@testable import JobsFeature

@Test func matchesFiltersByTitle() {
    let search = JobSearch()
    #expect(search.matches(for: "ios").isEmpty)   // internal, reachable
    // search.rank([])  // compile error: private stays private
}

Key Points

  • private, fileprivate, internal, package, public, open
  • package (Swift 5.9+) shares across modules in one package without a public API
  • @testable raises internal and package to public, never private
  • Needs ENABLE_TESTABILITY, so Release-configuration test runs fail
Q19

Explain structured concurrency in Swift: async let, TaskGroup, and what happens on cancellation.

IntermediateConcurrency

Answer

Structured concurrency means every child task has a parent that cannot finish before its children do, so lifetimes form a tree rather than a set of detached threads you have to track manually. async let starts a child immediately and you await it later, which is the right tool for a fixed, small number of parallel calls. withTaskGroup and withThrowingTaskGroup are for a dynamic number: you add tasks in a loop and consume results as they complete, which also gives you natural back-pressure control if you only keep N in flight at a time. The guarantees that matter in interviews are these. Cancellation is cooperative and propagates down the tree: cancelling the parent marks every child cancelled, but nothing stops running by magic, your code must check Task.isCancelled or call try Task.checkCancellation at a sensible boundary.

Most system APIs, including URLSession and Task.sleep, already throw on cancellation. If a child throws in a throwing group, the group cancels its remaining children and rethrows once you await, which is why you should still drain the group rather than returning early from inside the loop. Task { } and Task.detached break structure deliberately: Task { } inherits actor isolation and priority but not the parent's cancellation, while Task.detached inherits nothing, so treat detached as the escape hatch it is. Order is another trap: TaskGroup results arrive in completion order, not submission order, so if the UI needs stable ordering, return an index with each result and sort, or write into a preallocated array by index.

// Fixed fan-out: async let
func loadDashboard() async throws -> Dashboard {
    async let profile = api.profile()
    async let jobs    = api.recommendedJobs()
    async let alerts  = api.alerts()
    return try await Dashboard(profile: profile, jobs: jobs, alerts: alerts)
}

// Dynamic fan-out with bounded concurrency and stable ordering
func thumbnails(for urls: [URL], limit: Int = 4) async -> [UIImage?] {
    await withTaskGroup(of: (Int, UIImage?).self) { group in
        var results = [UIImage?](repeating: nil, count: urls.count)
        var next = 0
        func addTask() {
            guard next < urls.count else { return }
            let index = next; next += 1
            group.addTask { (index, await ImageLoader.shared.load(urls[index])) }
        }
        for _ in 0..<min(limit, urls.count) { addTask() }
        for await (index, image) in group {
            results[index] = image
            addTask()          // keep at most `limit` in flight
        }
        return results
    }
}

Key Points

  • async let for fixed fan-out, TaskGroup for dynamic fan-out
  • Cancellation propagates down the tree but is cooperative: check it
  • Group results arrive in completion order; carry an index if order matters
  • Task.detached inherits nothing, including isolation and priority
Q20

What problem do actors solve, and what is actor reentrancy?

IntermediateConcurrency

Answer

An actor protects its mutable state by serialising access: only one task executes actor-isolated code at a time, and the compiler forces every cross-actor call to be awaited, so you cannot accidentally read a property from two threads. That replaces the manual serial-queue-plus-discipline pattern with something the compiler verifies. @MainActor is a global actor whose executor is the main thread, which is why marking a view model @MainActor makes UI updates safe by construction rather than by convention. Reentrancy is the part candidates miss.

An actor is not a lock held across awaits. Whenever an actor method suspends at an await, the actor is free to run another queued call, so the state you read before the suspension may have changed after it. The classic bug is a cache method that checks for a missing key, awaits a network fetch, and writes the result: two concurrent callers both see the miss and both fetch, which doubles your API traffic and can produce two different objects for the same identity.

The fix is to store the in-flight Task, not just the finished value, so the second caller awaits the same task. The related performance point is actor hopping: every await that crosses an isolation boundary is a context switch, so a tight loop calling an actor method a thousand times is measurably slower than one call that does the loop inside the actor. Mark pure helpers nonisolated so they can run without hopping, and keep chatty interfaces coarse-grained.

actor ImageCache {
    private enum Entry { case ready(UIImage), inFlight(Task<UIImage, Error>) }
    private var entries: [URL: Entry] = [:]

    func image(for url: URL) async throws -> UIImage {
        // Reentrancy-safe: the second caller joins the same task
        if let entry = entries[url] {
            switch entry {
            case .ready(let image):  return image
            case .inFlight(let task): return try await task.value
            }
        }
        let task = Task<UIImage, Error> {
            let (data, _) = try await URLSession.shared.data(from: url)
            guard let image = UIImage(data: data) else { throw URLError(.cannotDecodeContentData) }
            return image
        }
        entries[url] = .inFlight(task)
        do {
            let image = try await task.value
            entries[url] = .ready(image)
            return image
        } catch {
            entries[url] = nil      // do not cache the failure forever
            throw error
        }
    }

    nonisolated var name: String { "ImageCache" }  // no hop needed
}
💡 Pro Tip: Any time you write 'check, await, then write' inside an actor, assume a second caller ran in the gap. Cache the Task rather than the value.
Q21

What does Sendable mean, and how do you deal with Swift 6 strict concurrency errors in an existing app?

IntermediateConcurrency

Answer

Sendable marks a type as safe to pass across isolation boundaries. Value types made only of Sendable members conform implicitly, final classes with only immutable let properties can conform explicitly, and anything with mutable shared state cannot unless you protect it. @Sendable on a closure means the closure itself can cross boundaries, so everything it captures must be Sendable too. Under the Swift 6 language mode these become errors rather than warnings, and the two you will meet on day one of a migration are 'capture of self with non-Sendable type in a Sendable closure' and 'main actor-isolated property cannot be referenced from a nonisolated context'.

The practical migration path is incremental rather than heroic. Keep the project in Swift 5 language mode and turn SWIFT_STRICT_CONCURRENCY from minimal to targeted, fix the warnings, then to complete, fix again, and only then flip the language mode to 6, module by module if you are modularised. Most of the fixes fall into four buckets: mark UI-facing types @MainActor, make model structs Sendable (usually free), convert shared mutable singletons into actors, and for legacy Objective-C types you cannot change, use @unchecked Sendable with a comment explaining what actually protects the state, or nonisolated(unsafe) on a single stored property.

Swift 6.2 softened the on-ramp considerably by letting a module default to main-actor isolation, which matches how most app code already behaves and removes a large class of spurious errors from single-threaded UI code. Interviewers want to hear that you know @unchecked is a promise you are making to the compiler, not a fix.

// 1. Value model: Sendable for free
struct Job: Sendable, Identifiable { let id: String; let title: String }

// 2. UI-facing state: isolate to the main actor
@MainActor @Observable
final class JobsViewModel {
    private(set) var jobs: [Job] = []
    func load() async { jobs = (try? await JobsAPI().fetch(page: 1)) ?? [] }
}

// 3. Shared mutable state: make it an actor
actor RequestCounter { private var count = 0; func bump() { count += 1 } }

// 4. Legacy type you cannot change: document the invariant
final class LegacyLogger: @unchecked Sendable {
    private let queue = DispatchQueue(label: "logger")   // all access is serialised here
    private var buffer: [String] = []
    func log(_ line: String) { queue.async { self.buffer.append(line) } }
}

// Build settings for an incremental migration
// SWIFT_STRICT_CONCURRENCY = targeted   (then complete, then SWIFT_VERSION = 6)

Key Points

  • Sendable = safe to cross isolation boundaries; value types usually get it free
  • Migrate with SWIFT_STRICT_CONCURRENCY minimal to targeted to complete, then language mode 6
  • @MainActor for UI types, actors for shared mutable state
  • @unchecked Sendable and nonisolated(unsafe) are promises, not fixes: document them
Q22

Combine or async/await in 2026: where does each still belong?

IntermediateConcurrency

Answer

async/await won for one-shot work: a network call, a disk read, a sequence of dependent operations. Code that used a Future and a chain of flatMap operators is shorter, easier to debug and produces readable stack traces when written with await. Combine still has a real niche in event streams where you need operators: debounce on a search field, throttle on scroll events, combineLatest across several inputs to compute a form's validity, removeDuplicates to suppress redundant renders, and merge across notification sources.

AsyncSequence and AsyncStream cover a lot of that ground with for await, and AsyncAlgorithms adds debounce and throttle, but Combine is still the shorter path when your app already uses it. The interop is worth knowing in both directions: any Publisher exposes .values as an AsyncSequence, and you can wrap a delegate or callback API in AsyncStream with a continuation, remembering to set onTermination so you unregister when the consumer goes away. Two Combine gotchas interviewers probe.

First, a subscription lives only as long as the AnyCancellable, so forgetting store(in: &cancellables) means the pipeline is torn down immediately and nothing happens, a bug that looks like the API never responded. Second, sink runs on whatever scheduler the upstream used, so a URLSession publisher delivers on a background thread and touching UI there is undefined behaviour, hence receive(on: DispatchQueue.main) before every UI sink. For new SwiftUI code, Observation plus async/await covers most needs, and reaching for Combine should be a deliberate choice driven by an operator you actually need.

// Combine: still the cleanest way to debounce a search field
searchTextPublisher
    .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
    .removeDuplicates()
    .filter { $0.count >= 2 }
    .sink { [weak self] query in self?.search(query) }
    .store(in: &cancellables)   // forget this and nothing ever fires

// Bridging a delegate API into an AsyncStream
func locationUpdates() -> AsyncStream<CLLocation> {
    AsyncStream { continuation in
        let observer = LocationObserver { continuation.yield($0) }
        manager.add(observer)
        continuation.onTermination = { _ in manager.remove(observer) }
    }
}

// Consuming it, with automatic cancellation on view disappear
.task {
    for await location in locationUpdates() {
        await viewModel.update(location)
    }
}

Key Points

  • async/await for one-shot work, Combine or AsyncSequence for event streams
  • debounce, throttle, combineLatest are the operators worth keeping Combine for
  • Missing store(in:) silently kills the subscription
  • AsyncStream.onTermination is how you unregister a bridged delegate
Q23

How does SwiftUI decide that a view is the same view, and why does state sometimes reset unexpectedly?

IntermediateSwiftUI

Answer

SwiftUI tracks views by identity, not by object reference, and there are two kinds. Structural identity comes from a view's position in the view tree: the same branch in the same place is the same view across renders. Explicit identity comes from .id(value) or from the id of an element in a ForEach.

State, animations and the lifetime of @StateObject are all attached to identity, so when identity changes, SwiftUI tears down the old view and its state and builds a new one. That explains the three classic surprises. First, an if/else creates two structurally distinct branches, so toggling the condition destroys any @State in the branch that disappears; if you want the state preserved, use a single view with a modifier or an opacity change instead of two branches.

Second, ForEach over an array by index or with an unstable id, for example a UUID generated inside the body, gives every row a new identity on every render, so text fields lose focus and animations restart. Use Identifiable models with a stable server id. Third, calling .id(someChangingValue) is a deliberate way to force a reset, which is useful for clearing a form but a disaster when applied accidentally to a scroll container.

AnyView is the other frequent culprit: it erases type information, so SwiftUI loses the structural detail it uses to diff efficiently, and it should be a last resort behind @ViewBuilder or a generic. For debugging, call Self._printChanges() inside body during development to see which property triggered a re-render, and check that your Equatable conformances are not accidentally comparing closures.

struct RowsScreen: View {
    @State private var jobs: [Job] = []
    @State private var showsFilters = false

    var body: some View {
        List {
            // Stable identity: reordering animates, state survives
            ForEach(jobs) { job in RowView(job: job) }

            // Wrong: index identity makes every row change identity on insert
            // ForEach(jobs.indices, id: \.self) { RowView(job: jobs[$0]) }
        }
        // Wrong: two structural branches, state in each is destroyed on toggle
        // if showsFilters { FilterView() } else { FilterView().opacity(0) }
        .overlay(alignment: .bottom) {
            FilterView().opacity(showsFilters ? 1 : 0)   // one identity, preserved
        }
        .onAppear { Self._printChanges() }
    }
}

struct RowView: View {
    let job: Job
    @State private var expanded = false   // tied to this row's identity
    var body: some View { Text(job.title).lineLimit(expanded ? nil : 1) }
}
💡 Pro Tip: If a text field loses focus every keystroke, look for a ForEach keyed on an index or on a freshly generated UUID.
Q24

How do you embed SwiftUI in a UIKit app and UIKit inside SwiftUI, and what breaks at the boundary?

IntermediateSwiftUI

Answer

Going SwiftUI into UIKit, you wrap the view in a UIHostingController and treat it as a normal child view controller: addChild, add its view, activate constraints, didMove(toParent:). Two details matter. The hosting controller has its own safe area and background, so a SwiftUI view pushed onto a navigation stack often needs the parent's navigation bar configured in UIKit rather than with SwiftUI modifiers.

And sizing was the historical pain point until sizingOptions arrived, letting the hosting controller adopt its SwiftUI content's intrinsic size so it plays well inside a stack view or a self-sizing cell. For table and collection cells, UIHostingConfiguration is the purpose-built API: set it as the cell's contentConfiguration and you get correct self-sizing and reuse without nesting controllers. Going the other way, UIViewRepresentable and UIViewControllerRepresentable have three parts. makeUIView creates the instance exactly once, updateUIView pushes new SwiftUI state into it and can be called very often, so it must be cheap and idempotent, and the Coordinator holds delegate conformances and any mutable bridging state.

The classic bug is writing to SwiftUI @State from inside updateUIView, which triggers another update and loops until the runtime warns you about modifying state during view update; route those changes through the coordinator and a callback instead. Also implement dismantleUIView when you own resources such as an AVPlayer or a map delegate. In interviews the follow-up is usually about navigation: mixing UINavigationController with NavigationStack in one flow is the most common source of state bugs, so pick one owner of navigation per flow and bridge at the screen boundary only.

// UIKit hosting SwiftUI, with correct self-sizing
let host = UIHostingController(rootView: JobDetailView(job: job))
host.sizingOptions = [.intrinsicContentSize]
addChild(host)
host.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(host.view)
NSLayoutConstraint.activate([
    host.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
    host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
    host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
host.didMove(toParent: self)

// SwiftUI hosting UIKit
struct SearchBar: UIViewRepresentable {
    @Binding var text: String

    func makeUIView(context: Context) -> UISearchBar {
        let bar = UISearchBar()
        bar.delegate = context.coordinator
        return bar
    }

    func updateUIView(_ bar: UISearchBar, context: Context) {
        if bar.text != text { bar.text = text }   // guard against update loops
    }

    func makeCoordinator() -> Coordinator { Coordinator(text: $text) }

    final class Coordinator: NSObject, UISearchBarDelegate {
        private let text: Binding<String>
        init(text: Binding<String>) { self.text = text }
        func searchBar(_ bar: UISearchBar, textDidChange value: String) { text.wrappedValue = value }
    }
}

Key Points

  • UIHostingController with sizingOptions, or UIHostingConfiguration inside cells
  • makeUIView runs once, updateUIView runs often and must be idempotent
  • Never mutate SwiftUI state directly from updateUIView; go through the Coordinator
  • Pick one navigation owner per flow instead of mixing NavigationStack and UINavigationController
Q25

How do you use Core Data safely from a background thread, and where does SwiftData fit now?

IntermediatePersistence

Answer

NSManagedObject and NSManagedObjectContext are not thread-safe, and the rule is absolute: a managed object belongs to the context that created it and may only be touched on that context's queue. NSPersistentContainer gives you a viewContext bound to the main queue for UI reads, and newBackgroundContext or performBackgroundTask for writes. Every access goes inside context.perform (async) or performAndWait, and you never pass an NSManagedObject between contexts, you pass its NSManagedObjectID and re-fetch with object(with:) on the destination.

Set viewContext.automaticallyMergesChangesFromParent to true so the UI sees background writes, and pick a merge policy deliberately: the default throws on conflict, while NSMergeByPropertyObjectTrumpMergePolicy lets the incoming write win, which is usually right for server-sourced data. For large imports, NSBatchInsertRequest and NSBatchDeleteRequest bypass the object graph entirely and are an order of magnitude faster, but they do not fire notifications, so you must merge changes into the view context yourself using persistent history tracking. For list UIs, NSFetchedResultsController remains the efficient path because it batches faults and drives diffable snapshots.

SwiftData is the modern layer: @Model classes, a ModelContainer, and @Query in SwiftUI views, with ModelActor for background work. It is genuinely nicer for greenfield apps on recent OS versions, but teams shipping to older iOS versions or needing fine-grained control over fetch batching, persistent history and complex migrations still choose Core Data, and plenty of production apps run SwiftData over an existing Core Data store because the two share the same underlying persistence.

let container = NSPersistentContainer(name: "Jobs")
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

// Write on a background context, hand back only the object ID
func importJobs(_ payload: [JobDTO]) async throws -> [NSManagedObjectID] {
    try await container.performBackgroundTask { context in
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        var ids: [NSManagedObjectID] = []
        for dto in payload {
            let job = JobEntity(context: context)
            job.id = dto.id
            job.title = dto.title
            ids.append(job.objectID)
        }
        try context.save()
        return ids
    }
}

// Read it back on the main queue
@MainActor
func title(for id: NSManagedObjectID) -> String? {
    (container.viewContext.object(with: id) as? JobEntity)?.title
}

Key Points

  • Managed objects are context-bound: pass NSManagedObjectID, never the object
  • All access inside perform / performAndWait on the owning queue
  • Batch insert and delete are fast but need persistent history merging
  • SwiftData for new apps on recent OS versions; Core Data for control and back-compat
Q26

A screen leaks memory only after the user visits it twice. How do you find the cause?

IntermediateDebugging

Answer

Start by confirming it is a leak and not just cache growth. Add a print or a breakpoint in deinit of the view controller and its view model, push the screen, pop it, and see whether deinit runs. If it does not, run the app, reach the suspected state, and hit the Debug Memory Graph button in Xcode.

With Malloc Stack Logging enabled in the scheme's Diagnostics tab, selecting the surviving instance shows you every object still holding a strong reference plus the allocation backtrace, which usually names the exact closure or delegate. Xcode flags obvious cycles with a purple exclamation mark, but the harder case is abandoned memory, where nothing is technically cyclic yet a singleton, a static array, a NotificationCenter observer registered with the block-based API, or a Combine cancellable set keeps growing. For that, the Allocations instrument with generation marks is the tool: mark a generation, perform the push-and-pop cycle five times, mark again, and inspect what persists in each generation.

Repeat offenders in real codebases are NotificationCenter.default.addObserver(forName:object:queue:using:) whose returned token is never removed, a Timer scheduled with a target of self, a delegate declared var instead of weak var, an escaping completion handler stored on a long-lived service, and CADisplayLink retaining its target. In a Swift Concurrency codebase add one more: a Task stored on the view model that captures self strongly and never finishes, because the object cannot deallocate while the task lives. Cancel tasks in deinit or use [weak self] with an early return.

final class OffersViewController: UIViewController {
    private var observer: NSObjectProtocol?
    private var timer: Timer?
    private var refreshTask: Task<Void, Never>?

    override func viewDidLoad() {
        super.viewDidLoad()
        // Block observers return a token you MUST remove
        observer = NotificationCenter.default.addObserver(
            forName: .offersUpdated, object: nil, queue: .main
        ) { [weak self] _ in self?.reload() }

        // Timer retains its target; use the block form plus weak self
        timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
            self?.reload()
        }
    }

    deinit {
        if let observer { NotificationCenter.default.removeObserver(observer) }
        timer?.invalidate()
        refreshTask?.cancel()
        print("OffersViewController deinit")
    }
}
💡 Pro Tip: Enable Malloc Stack Logging in the scheme Diagnostics tab before you reproduce. Without it the memory graph shows you the cycle but not where the reference was created.
Q27

Users report the feed stutters while scrolling. Walk through diagnosing and fixing the hitches.

IntermediatePerformance

Answer

First measure, because scroll stutter has several distinct causes with different fixes. Attach the Time Profiler with the Hangs and Animation Hitches instruments, scroll the feed, and look at what the main thread is doing during the dropped frames. At 120 Hz you have roughly 8 milliseconds per frame, so any main-thread work above a couple of milliseconds per cell is already a risk.

The usual culprits, in the order I check them: image decoding on the main thread, because assigning a large JPEG to a UIImageView decodes lazily during rendering, and the fix is to decode off the main thread and hand over a display-ready image with byPreparingForDisplay or byPreparingThumbnail at the exact target size; synchronous disk or Core Data access inside cellForRowAt, which belongs in a prefetch step; string sizing and date formatting per cell, because creating a DateFormatter is expensive and should be cached in a static; and offscreen rendering from masksToBounds with a corner radius on top of an image, plus shadows without an explicit shadowPath, both of which force an extra render pass per cell. Beyond that, deep view hierarchies and Auto Layout constraint churn inside cells cost real time, so prefer a flat layout and avoid rebuilding constraints on reuse. Use UITableViewDataSourcePrefetching or UICollectionViewDataSourcePrefetching to start image loads before the row is visible, and cancel them in cancelPrefetchingForRowsAt. Finally, verify on a real low-end device, because a base-model iPhone on a warm thermal state behaves nothing like a simulator on an M-series Mac, and most Indian consumer apps see the complaints from exactly that hardware tier.

// Decode off the main thread, hand back a display-ready image
func thumbnail(from data: Data, size: CGSize) async -> UIImage? {
    await Task.detached(priority: .utility) {
        guard let image = UIImage(data: data) else { return nil }
        return await image.byPreparingThumbnail(ofSize: size)
    }.value
}

// Avoid the offscreen pass: shadow with an explicit path
card.layer.shadowPath = UIBezierPath(roundedRect: card.bounds, cornerRadius: 12).cgPath
card.layer.shadowOpacity = 0.12
card.layer.shouldRasterize = false

// Cache expensive formatters instead of building one per cell
enum Formatters {
    static let posted: DateFormatter = {
        let f = DateFormatter()
        f.locale = Locale(identifier: "en_IN")
        f.dateStyle = .medium
        return f
    }()
}

// Prefetch and cancel with the scroll direction
func collectionView(_ cv: UICollectionView, prefetchItemsAt paths: [IndexPath]) {
    paths.forEach { loader.start(items[$0.item].imageURL) }
}
func collectionView(_ cv: UICollectionView, cancelPrefetchingForItemsAt paths: [IndexPath]) {
    paths.forEach { loader.cancel(items[$0.item].imageURL) }
}

Key Points

  • Measure with Time Profiler plus the Hangs and Animation Hitches instruments
  • Decode images off-thread at the display size, not the source size
  • shadowPath and avoiding masked corners over images kill offscreen passes
  • Prefetch APIs plus cancellation, and always test on a low-end device
Q28

Why did diffable data sources replace reloadData, and what causes the 'invalid number of items' crash?

IntermediateUIKit

Answer

With the old API you mutated your model array and then told the table view what changed with insertRows, deleteRows and moveRow inside beginUpdates and endUpdates. If your arithmetic disagreed with the data source by even one row, UIKit raised the familiar exception saying the number of items after the update does not equal the number before plus inserted minus deleted. Diffable data sources invert the contract: you build an NSDiffableDataSourceSnapshot of section identifiers and item identifiers, apply it, and the framework computes the difference and animates it.

There is no arithmetic left to get wrong, and applying from a background queue is supported, so a large diff does not block the main thread. The requirements are strict and are exactly what interviewers probe. Identifiers must be Hashable and, critically, unique within the snapshot: appending the same identifier twice traps at runtime with a duplicate identifiers message.

Identifiers should be identity, not content, so use the server id rather than the whole model struct, otherwise every content change looks like a delete plus insert and the animation flickers. When only the content changes, call reconfigureItems rather than reloadItems, because reconfigure reuses the existing cell and keeps animations smooth. Pair diffable data sources with UICollectionViewCompositionalLayout, which lets you describe item, group and section sizing declaratively, including orthogonal scrolling sections for the horizontal carousels every Indian shopping and food app ships, and with UICollectionView.CellRegistration so cell configuration is type-safe and registration happens in one place.

enum Section: Hashable { case featured, all }

private lazy var dataSource: UICollectionViewDiffableDataSource<Section, Job.ID> = {
    let cell = UICollectionView.CellRegistration<UICollectionViewListCell, Job.ID> { cell, _, id in
        guard let job = self.store[id] else { return }
        var content = cell.defaultContentConfiguration()
        content.text = job.title
        content.secondaryText = job.company
        cell.contentConfiguration = content
    }
    return UICollectionViewDiffableDataSource(collectionView: collectionView) { cv, indexPath, id in
        cv.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: id)
    }
}()

func apply(_ jobs: [Job], animated: Bool = true) {
    var snapshot = NSDiffableDataSourceSnapshot<Section, Job.ID>()
    snapshot.appendSections([.featured, .all])
    snapshot.appendItems(jobs.filter(\.isFeatured).map(\.id), toSection: .featured)
    snapshot.appendItems(jobs.map(\.id), toSection: .all)   // duplicate IDs would trap
    dataSource.apply(snapshot, animatingDifferences: animated)
}
💡 Pro Tip: Snapshot on identifiers, not model values. If your identifier changes when the title changes, every edit animates as a delete and insert.
Q29

Design a networking layer that survives flaky connectivity. What do you retry and how?

IntermediateNetworking

Answer

Start with the boundaries. One protocol for the transport so tests can substitute a stub, one place that builds URLRequests including auth headers, one decoder configured once, and typed errors that distinguish transport failure, HTTP status failure and decoding failure, because retrying a decoding error is pointless while retrying a 503 is sensible. Retry only idempotent operations: GET, PUT and DELETE are safe, POST is not unless the server supports an idempotency key, which is exactly how payment APIs used across Indian fintech expect you to call them.

Use exponential backoff with jitter, capped at three or four attempts, because synchronized retries from thousands of devices after a backend blip are how you turn a partial outage into a full one. Respect Retry-After when the server sends it on a 429 or 503. Handle 401 with a single-flight token refresh: if two screens each get a 401 and both refresh, one refresh token wins and the other invalidates the session, so route refreshes through an actor that returns the same in-flight task to every caller.

Set waitsForConnectivity on the session configuration so a request made in a lift waits rather than failing instantly. Layer caching deliberately: URLCache plus server ETag headers gives cheap conditional GETs, and for content the user should see offline, persist decoded models in your own store rather than relying on the HTTP cache. Finally, instrument it: log status code, duration and a request identifier, because without that you cannot tell a backend regression from a client bug.

func send<T: Decodable>(_ request: URLRequest, as type: T.Type, attempts: Int = 3) async throws -> T {
    var lastError: Error = URLError(.unknown)
    for attempt in 0..<attempts {
        do {
            let (data, response) = try await session.data(for: request)
            guard let http = response as? HTTPURLResponse else { throw APIError.transport }
            switch http.statusCode {
            case 200..<300:
                return try decoder.decode(T.self, from: data)
            case 401:
                try await tokenRefresher.refreshOnce()   // actor, single-flight
                throw APIError.retryable
            case 429, 500...599:
                let hinted = http.value(forHTTPHeaderField: "Retry-After").flatMap(Double.init)
                throw APIError.backoff(hinted)
            default:
                throw APIError.status(http.statusCode)   // 4xx: do not retry
            }
        } catch let error as APIError where error.isRetryable {
            lastError = error
            let base = pow(2.0, Double(attempt)) * 0.5
            let jitter = Double.random(in: 0...0.3)
            try await Task.sleep(for: .seconds(error.hintedDelay ?? base + jitter))
        }
    }
    throw lastError
}

Key Points

  • Retry only idempotent calls, or POSTs guarded by an idempotency key
  • Exponential backoff with jitter, honour Retry-After, cap attempts
  • Single-flight token refresh through an actor, or you log users out
  • URLCache plus ETag for cheap revalidation; persist models for real offline
Q30

How do push notifications work end to end, and why do silent pushes sometimes never arrive?

IntermediateNotifications

Answer

The app registers with UNUserNotificationCenter for authorisation, then calls UIApplication.shared.registerForRemoteNotifications. APNs returns a device token to didRegisterForRemoteNotificationsWithDeviceToken, which you upload to your backend. Tokens change on restore, reinstall and sometimes on OS update, so upload on every launch, not just the first.

Your server signs a request to APNs using a p8 auth key with the key ID and team ID, targeting either the sandbox or production host depending on the build's aps-environment entitlement, which is why a notification that works on a debug build silently fails on TestFlight when the backend is still pointed at sandbox. The payload's aps dictionary carries alert, badge, sound, and either content-available for a silent push or mutable-content for one you want to modify. A Notification Service Extension intercepts a mutable push and gets roughly thirty seconds to download an image or decrypt the body before it must call the content handler, and if it runs out of time or memory the original payload is shown instead.

Silent pushes are the part interviewers press on: they are explicitly best-effort. iOS throttles them by app usage, budget and power state, they are dropped entirely in Low Power Mode, and a user who force-quit the app will not receive them at all. So never design a feature that requires a silent push to arrive; treat it as an optimisation over a normal fetch. Also set apns-priority to 5 for silent pushes and use a collapse identifier so the system can coalesce a burst into one delivery.

import UserNotifications

func registerForPush() async {
    let center = UNUserNotificationCenter.current()
    let granted = (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) ?? false
    guard granted else { return }
    await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
}

// Show a banner while the app is in the foreground
func userNotificationCenter(_ center: UNUserNotificationCenter,
                            willPresent notification: UNNotification) async
                            -> UNNotificationPresentationOptions {
    [.banner, .list, .sound]
}

// Notification Service Extension: attach media, then always call the handler
final class NotificationService: UNNotificationServiceExtension {
    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler handler: @escaping (UNNotificationContent) -> Void) {
        let content = request.content.mutableCopy() as! UNMutableNotificationContent
        Task {
            if let attachment = await downloadAttachment(from: content.userInfo) {
                content.attachments = [attachment]
            }
            handler(content)
        }
    }

    override func serviceExtensionTimeWillExpire() {
        // Called at ~30s: deliver something rather than nothing
    }
}

Key Points

  • Device tokens rotate: upload on every launch
  • aps-environment decides sandbox vs production APNs host; TestFlight is production
  • Silent pushes are best-effort, throttled, and dead after force-quit
  • Service extensions have a hard time budget: always call the content handler
Q31

How do Universal Links work, and how do you debug one that opens Safari instead of your app?

IntermediateDeep Linking

Answer

A Universal Link is an ordinary https URL that opens your app when installed and your website when not. Three things must line up. The site serves an apple-app-site-association file at https://domain/.well-known/apple-app-site-association, over HTTPS with no redirects, with Content-Type application/json and no .json extension, listing appIDs in the form TEAMID.bundleid plus the path patterns you claim.

The app carries the Associated Domains capability with applinks:domain in its entitlements. And the app handles the incoming NSUserActivity of type NSUserActivityTypeBrowsingWeb, or onOpenURL in SwiftUI. When it opens Safari instead, work through the checklist in order: fetch the AASA with curl and confirm the exact content type and the absence of a redirect, confirm the team prefix is the app ID prefix rather than the bundle seed, and remember that iOS caches the association, so after fixing the file you must delete and reinstall the app or use the developer mode diagnostics in Settings to force a refresh.

Also know the behavioural traps: a link typed directly into Safari's address bar never opens the app by design, a link tapped inside some in-app browsers is handled by that app instead, and once the user taps the breadcrumb to open in Safari, iOS remembers that choice for the domain until they choose otherwise. Custom URL schemes still have a place for OAuth callbacks and app-to-app handoff, but they can be claimed by any app, so never treat data arriving on a custom scheme as trusted. Always validate and sanitise the incoming path before routing.

// Served at https://goodspace.ai/.well-known/apple-app-site-association
// {
//   "applinks": {
//     "details": [{
//       "appIDs": ["ABCDE12345.ai.goodspace.app"],
//       "components": [
//         { "/": "/jobs/*", "comment": "job detail" },
//         { "/": "/apply/*", "?": { "src": "?*" } }
//       ]
//     }]
//   }
// }

@main
struct JobsApp: App {
    @State private var route: Route?

    var body: some Scene {
        WindowGroup {
            RootView(route: $route)
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    guard let url = activity.webpageURL else { return }
                    route = Route(url: url)
                }
                .onOpenURL { url in route = Route(url: url) }  // custom scheme
        }
    }
}

struct Route {
    init?(url: URL) {
        guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
              comps.host == "goodspace.ai" || url.scheme == "goodspace" else { return nil }
        // validate the path allow-list here before routing
    }
}
💡 Pro Tip: curl -I the AASA file first. A 301 to www, a text/html content type, or a .json extension each silently break Universal Links, and none of them produce an error in Xcode.
Q32

How do you make a view model testable, and what does an async test look like in Swift Testing?

IntermediateTesting

Answer

Testability is a design property, not a test-writing trick. The view model should depend on protocols, not concrete singletons: an API client protocol, a clock or date provider instead of Date(), and a storage protocol instead of UserDefaults.standard. Inject them through the initialiser with production defaults so app code stays terse while tests can substitute fakes.

Anything that reads the current time, generates a UUID, or reaches the network is a source of flakiness unless it is injected. Swift Testing, the newer framework built around the @Test macro, is now the default for new test targets and coexists with XCTest in the same project. It gives you #expect and #require instead of the XCTAssert family, real async support without expectation juggling, parameterised tests with arguments, traits like .tags and .timeLimit, and parallel execution by default, which itself surfaces hidden shared state in your code.

XCTest remains required for performance tests with measure and for UI tests. Two habits interviewers look for. Assert on observable behaviour, not on internal call counts, otherwise every refactor breaks the suite.

And test the failure paths, because that is where mobile bugs live: what the view model exposes when the API returns 500, when decoding fails, when the task is cancelled mid-flight, and when the same load is triggered twice in quick succession. For anything @MainActor, annotate the test so the compiler is happy rather than sprinkling MainActor.run calls through the test body.

import Testing
@testable import JobsFeature

struct FakeAPI: JobsAPIProtocol {
    var result: Result<[Job], Error>
    func fetch(page: Int) async throws -> [Job] { try result.get() }
}

@MainActor
@Test("shows jobs after a successful load")
func loadsJobs() async {
    let model = JobsViewModel(api: FakeAPI(result: .success([Job(id: "1", title: "iOS Engineer")])))
    await model.load()
    #expect(model.state == .loaded)
    #expect(model.jobs.count == 1)
}

@MainActor
@Test("surfaces a retryable error on 500")
func surfacesServerError() async {
    let model = JobsViewModel(api: FakeAPI(result: .failure(APIError.status(500))))
    await model.load()
    #expect(model.state == .failed(retryable: true))
    #expect(model.jobs.isEmpty)
}

@Test(arguments: [0, -1, 999])
func rejectsInvalidPages(page: Int) throws {
    #expect(throws: ValidationError.self) { try PageRequest(page: page) }
}

Key Points

  • Inject API, clock and storage as protocols with production defaults
  • Swift Testing: @Test, #expect, #require, parameterised arguments, parallel by default
  • XCTest still needed for measure and UI tests; both can live in one project
  • Test failure paths, cancellation and double-invocation, not just the happy path
Q33

Your XCUITest suite is flaky in CI but passes locally. What do you change?

IntermediateTesting

Answer

Flaky UI tests almost always come from timing, from shared state, or from tests depending on the network. Fix them in that order. For timing, delete every sleep and replace it with waitForExistence(timeout:) or an XCTNSPredicateExpectation, because a fixed sleep is both slower than it needs to be on a fast machine and too short on a loaded CI runner.

Query for elements by a stable accessibilityIdentifier rather than by visible label text, since labels change with localisation, with A/B tests, and with the copy team. For shared state, launch each test with a clean slate: pass launch arguments that reset the container, skip onboarding, disable animations, and force a known locale, then read them at startup. Animations are a common hidden cause, so set UIView.setAnimationsEnabled(false) under a test flag.

For the network, stub it. A UI test that hits staging is a staging monitor, not a test: run a local stub server or inject canned responses through a launch environment variable, so the same tap always produces the same screen. Beyond that, use Xcode test plans to configure retry-on-failure with a small retry count while you stabilise, run tests in parallel across simulator clones to cut wall-clock time, and always capture the failure artefacts: XCTAttachment screenshots, the .xcresult bundle and the simulator log, because a CI failure you cannot reproduce locally is only debuggable from artefacts. Finally, keep the UI suite small and focused on critical journeys such as login, search, apply and payment, and push everything else into unit tests.

final class ApplyFlowUITests: XCTestCase {
    private var app: XCUIApplication!

    override func setUp() {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments += ["-uitest", "-skipOnboarding", "-AppleLanguages", "(en)"]
        app.launchEnvironment["STUB_SERVER"] = "http://127.0.0.1:8080"
        app.launch()
    }

    func testApplyToJob() {
        let firstJob = app.cells["job.cell.0"]          // accessibilityIdentifier
        XCTAssertTrue(firstJob.waitForExistence(timeout: 10))
        firstJob.tap()

        let applyButton = app.buttons["job.apply"]
        XCTAssertTrue(applyButton.waitForExistence(timeout: 5))
        applyButton.tap()

        XCTAssertTrue(app.staticTexts["apply.success"].waitForExistence(timeout: 15))
        add(XCTAttachment(screenshot: app.screenshot()))
    }
}

// In the app target
if ProcessInfo.processInfo.arguments.contains("-uitest") {
    UIView.setAnimationsEnabled(false)
    Persistence.resetForTesting()
}
💡 Pro Tip: If a test needs a sleep to pass, it is hiding a real race that users will hit too. Find the actual completion signal and wait on that.
Q34

The app is 180 MB and downloads are dropping. How do you reduce app size?

IntermediateApp Store & Release

Answer

Measure first, from the App Store Connect app size report for a real build, which breaks the download and install size down per device variant. Guessing from the .app folder on disk is misleading because thinning has not happened yet. App thinning gives you slicing (only the assets and the architecture slice a given device needs), on-demand resources, and bitcode is no longer part of the picture.

The biggest wins in practice are almost always assets and dependencies. Move every image into an asset catalog so slicing applies, prefer vector PDF or SVG assets with Preserve Vector Data for icons instead of three raster scales, compress remaining images, and delete the ones nobody references, which a script over the catalog plus a grep across the codebase will find in an afternoon. Audit dependencies ruthlessly: a single analytics or chat SDK can add tens of megabytes, and duplicated transitive dependencies are common.

Prefer static linking for small internal modules and be aware that each dynamic framework adds both size and launch cost. Enable dead code stripping and make sure DEPLOYMENT_POSTPROCESSING and strip settings are on for Release, use SWIFT_COMPILATION_MODE wholemodule, and check that you are not shipping debug symbols inside the binary. Large videos, fonts and ML models belong in on-demand resources or should be downloaded on first use. This matters commercially in India: many users install over mobile data on constrained plans, and going below the cellular download warning threshold measurably improves install completion, which is why growth teams at Indian consumer apps track binary size as a release metric.

Key Points

  • Use the App Store Connect size report per device variant, not the local .app size
  • Asset catalogs enable slicing; vectors with Preserve Vector Data beat three raster scales
  • Audit SDKs, prefer static linking for internal modules, strip dead code in Release
  • Move models, fonts and video to on-demand resources or first-use download
Q35

How do you localise an app for Indian languages, and what does the String Catalog change?

IntermediateLocalization & Accessibility

Answer

Never build user-facing strings by concatenation. Use String(localized:) in Swift or the automatic extraction SwiftUI does for Text literals, and put every string in a String Catalog, the .xcstrings format that replaced .strings and .stringsdict files in recent Xcode versions. The catalog is a single JSON-backed file that Xcode edits with a real UI, tracks translation state per language, extracts new strings on build, and handles plural and device variations in the same place, which is a large practical improvement over maintaining a separate stringsdict.

Plurals matter more than teams expect for Indian languages: Hindi and several others have different plural rules from English, so hardcoding a trailing s or writing your own count check produces text that reads as broken to native speakers. For layout, remember that translated strings are frequently longer, Devanagari and Tamil have taller line heights that clip fixed-height labels, and any hardcoded width will break, so test with the pseudolanguage options in the scheme, Double-Length Pseudolanguage and Right-to-Left Pseudolanguage, before you have real translations. Use leading and trailing constraints instead of left and right so RTL mirroring works, and format numbers, currency and dates with FormatStyle or a locale-aware formatter rather than manual string building, because ₹ placement, the Indian digit grouping of 1,00,000, and date order all come from the locale. Accessibility rides along with this work: support Dynamic Type with scaled fonts rather than fixed point sizes, give every control an accessibility label, and verify with VoiceOver, which is also a Play Store and App Store review consideration.

// Strings are extracted into Localizable.xcstrings automatically
let title = String(localized: "jobs.header", defaultValue: "Jobs near you",
                   comment: "Header above the job feed")

// Plurals live in the same catalog and follow each language's rules
let subtitle = String(localized: "jobs.count",
                      defaultValue: "^[\(count) job](inflect: true) matched")

// Locale-aware money and dates, never manual formatting
let salary = Decimal(1_200_000).formatted(.currency(code: "INR").locale(Locale(identifier: "en_IN")))
let posted = job.postedAt.formatted(.relative(presentation: .named))

// Dynamic Type instead of fixed sizes
Text(title)
    .font(.headline)
    .lineLimit(2)
    .minimumScaleFactor(0.8)
    .accessibilityAddTraits(.isHeader)

// UIKit equivalent
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17))
label.adjustsFontForContentSizeCategory = true

Key Points

  • String Catalogs (.xcstrings) replace .strings and .stringsdict and handle plurals inline
  • Test with Double-Length and RTL pseudolanguages before translations exist
  • Use leading/trailing anchors and FormatStyle for currency, digits and dates
  • Dynamic Type with scaled fonts, plus VoiceOver labels on every control
Q36

Compare MVC, MVVM and unidirectional architectures for iOS, and say how you would structure a new feature.

IntermediateArchitecture

Answer

Apple's MVC puts the view controller between model and view, and in practice the controller absorbs networking, formatting, navigation and state until it is three thousand lines long, which is why the pattern is nicknamed Massive View Controller. MVVM extracts presentation state and logic into a view model that exposes ready-to-render values and takes plain inputs, so the controller or SwiftUI view becomes thin and the logic becomes unit-testable without any UI. It is the default in most Indian product teams because it is a small step from MVC and pairs naturally with SwiftUI: an @Observable, @MainActor view model with private(set) state and async methods.

Unidirectional architectures such as Redux-style stores or the Composable Architecture go further: state is a single value, changes only happen through actions handled by a reducer, and side effects are described as values rather than performed inline. That buys you time-travel debugging, exhaustive tests and predictable state, at the cost of boilerplate and a steeper onboarding curve for new joiners. VIPER shows up in older enterprise codebases and adds a router and an interactor, which mostly helps large teams enforce boundaries.

For a new feature I would use MVVM with a state enum rather than scattered booleans, protocol-injected dependencies, a coordinator or a NavigationStack path owned above the screen so the view model never presents anything itself, and repository types that hide whether data came from the network or the cache. The point interviewers care about is the reasoning: name the trade-off and the team context, because 'we use MVVM because everyone does' is a weaker answer than explaining what it makes testable.

@MainActor @Observable
final class JobsViewModel {
    enum State: Equatable { case idle, loading, loaded([Job]), failed(retryable: Bool) }

    private(set) var state: State = .idle
    private let api: JobsAPIProtocol
    private var loadTask: Task<Void, Never>?

    init(api: JobsAPIProtocol = JobsAPI()) { self.api = api }

    func load() {
        loadTask?.cancel()                 // supersede, never stack requests
        state = .loading
        loadTask = Task { [weak self] in
            guard let self else { return }
            do { state = .loaded(try await api.fetch(page: 1)) }
            catch is CancellationError { }
            catch { state = .failed(retryable: (error as? APIError)?.isRetryable ?? false) }
        }
    }

    deinit { loadTask?.cancel() }
}

// The view renders the state enum; no booleans to get out of sync
switch model.state {
case .idle, .loading:          ProgressView()
case .loaded(let jobs):        JobsList(jobs: jobs)
case .failed(let retryable):   ErrorView(showsRetry: retryable) { model.load() }
}

Key Points

  • MVVM is the pragmatic default and pairs cleanly with @Observable and @MainActor
  • Model screen state as one enum, not several independent booleans
  • Keep navigation above the view model, in a coordinator or NavigationStack path
  • Unidirectional stores buy predictability and tests at the cost of boilerplate
Q37

A crash spikes to 2% of sessions after a release. How do you triage it from the crash report?

AdvancedProduction Debugging

Answer

Start by classifying the termination, because the fix differs completely by type. EXC_BAD_ACCESS with SIGSEGV or SIGBUS is a memory error: a dangling unowned reference, a use-after-free through an unsafe pointer, or an Objective-C object over-released. EXC_BREAKPOINT with SIGTRAP is usually a Swift runtime trap, so a force unwrap of nil, an array index out of range, an integer overflow, or a failed precondition.

EXC_CRASH with SIGABRT is typically an uncaught Objective-C exception, and the report names it. Then there are the terminations that are not crashes in the usual sense: exception code 0x8badf00d means the watchdog killed you for taking too long, usually at launch or when returning from background, and EXC_RESOURCE with a memory flavour means jetsam killed you for exceeding the memory limit. Next, symbolicate.

Xcode Organizer symbolicates automatically when the matching dSYM is available, so your build pipeline must upload dSYMs for every release, including bitcode-free rebuilds and each app extension. If you only have a raw report, atos with the UUID-matched dSYM and the load address resolves individual frames. Read the crashing thread first, then check whether the crash is on the main thread and what the other threads are doing, since a main-thread crash in a concurrency codebase often follows a mutation from a background thread elsewhere.

Correlate with MetricKit, which delivers daily MXCrashDiagnostic and MXHangDiagnostic payloads with call stacks straight from real devices, and with your own OSLog signposts around recent feature flags. If the spike lines up exactly with a rollout, use phased release to pause it while you fix.

import MetricKit
import os

final class DiagnosticsCollector: NSObject, MXMetricManagerSubscriber {
    private let log = Logger(subsystem: "ai.goodspace.app", category: "diagnostics")

    func start() { MXMetricManager.shared.add(self) }

    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            payload.crashDiagnostics?.forEach { crash in
                log.error("crash type=\(crash.exceptionType?.intValue ?? -1) code=\(crash.exceptionCode?.intValue ?? -1) signal=\(crash.signal?.intValue ?? -1)")
                upload(crash.callStackTree.jsonRepresentation(), kind: "crash")
            }
            payload.hangDiagnostics?.forEach { hang in
                log.error("hang duration=\(hang.hangDuration.value) s")
                upload(hang.callStackTree.jsonRepresentation(), kind: "hang")
            }
        }
    }
}

// Symbolicating one frame by hand when Organizer cannot
// atos -arch arm64 -o Jobs.app.dSYM/Contents/Resources/DWARF/Jobs \
//      -l 0x1049a4000 0x1049f21c8

Key Points

  • Classify first: SIGSEGV memory, SIGTRAP Swift trap, SIGABRT exception
  • 0x8badf00d is the watchdog, EXC_RESOURCE memory is jetsam, neither is a normal crash
  • Upload dSYMs for the app and every extension on every build
  • MetricKit gives real-device crash and hang stacks; phased release lets you pause a bad rollout
Q38

Cold launch takes 2.5 seconds on an older iPhone. How do you get it under a second?

AdvancedPerformance

Answer

Split launch into pre-main and post-main, because they have different fixes. Pre-main is dyld work: loading and linking dynamic libraries, binding and rebasing, running Objective-C class registration and any +load methods, and running C++ static initialisers. Post-main is your own code up to the first frame.

Measure with the App Launch template in Instruments, which gives you a timeline broken into those phases, and track it over time with MetricKit's launch metric from real users rather than trusting one run on your own device. Apple's long-standing guidance is to reach the first frame in roughly 400 milliseconds, and the watchdog kills you outright around 20 seconds. On the pre-main side, the dominant cost in most large apps is the number of dynamic frameworks, since each one costs measurable time to load; converting internal modules to static libraries, or using mergeable libraries so several dylibs merge into the main binary for release builds, is often the single biggest win.

Remove +load methods and swizzling from third-party SDKs where you can, and replace static initialisers with lazily computed values. On the post-main side, the pattern is to defer everything that is not needed for the first frame: analytics, crash SDK beyond the handler installation, remote config fetch, image caches, database warmup and A/B assignment can all run after the first frame or on first use. Never make a synchronous network call or a blocking keychain read on the launch path, and never decode a large JSON cache synchronously. Finally, use a static launch screen rather than one that requires code, and test on the oldest device you support, which for the Indian market is usually several generations behind your own.

@main
struct JobsApp: App {
    init() {
        // Only what the first frame genuinely needs
        CrashReporter.installHandler()
        Appearance.apply()
    }

    var body: some Scene {
        WindowGroup {
            RootView()
                .task(priority: .utility) {
                    // Everything else, after the first frame is on screen
                    await Analytics.start()
                    await RemoteConfig.refresh()
                    await ImageCache.warm()
                }
        }
    }
}

// Lazy statics instead of eager work at load time
enum Services {
    static let database: Database = {   // built on first access, not at launch
        Database(url: .applicationSupportDirectory.appending(path: "jobs.sqlite"))
    }()
}

# Measure the phases
# Instruments > App Launch template, or the legacy dyld statistics env var
# DYLD_PRINT_STATISTICS = 1 in the scheme's Run > Arguments > Environment Variables

Key Points

  • Pre-main = dyld, dylib count, +load, static initialisers; post-main = your bootstrap
  • Fewer dynamic frameworks, or mergeable libraries, is usually the biggest pre-main win
  • Defer analytics, remote config and cache warmup until after the first frame
  • Track it with MetricKit on real devices and test on the oldest supported iPhone
Q39

How does BGTaskScheduler work, and why does your background refresh almost never run?

AdvancedBackground Execution

Answer

You register handlers for identifiers listed under BGTaskSchedulerPermittedIdentifiers in Info.plist, and registration must happen before application(_:didFinishLaunchingWithOptions:) returns, otherwise the system throws when a task launches you. There are two main request types. BGAppRefreshTaskRequest is for short updates, on the order of thirty seconds, to freshen content before the user opens the app.

BGProcessingTaskRequest is for longer maintenance such as database compaction or model training, and you can require external power and network connectivity, in which case the system typically runs it overnight while charging. Every task must set an expirationHandler that cancels work promptly and must call setTaskCompleted(success:) exactly once, or the system penalises your app's future scheduling. The reason refresh rarely runs is that the scheduler is a budget system, not a timer. iOS weights how often the user actually opens your app, battery level, Low Power Mode, thermal state, network conditions and whether Background App Refresh is even enabled, and an app the user opens once a month will effectively never get a slot.

Force-quitting from the app switcher stops scheduled tasks entirely until the user launches again. So design for it: schedule the next request at the end of each handler because a request is one-shot, keep the work idempotent and resumable, and never make correctness depend on background execution. To test, run on a device, pause in the debugger and use the private simulate-launch call in LLDB, since waiting for a real slot is impractical. For genuinely time-sensitive delivery, use a normal push notification rather than pretending background refresh is a scheduler.

import BackgroundTasks

let refreshID = "ai.goodspace.app.refresh"

// Must be registered before didFinishLaunching returns
BGTaskScheduler.shared.register(forTaskWithIdentifier: refreshID, using: nil) { task in
    guard let task = task as? BGAppRefreshTask else { return }
    scheduleNextRefresh()                 // requests are one-shot

    let work = Task {
        do {
            try await FeedStore.shared.refresh()
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }
    task.expirationHandler = {
        work.cancel()
        task.setTaskCompleted(success: false)   // never skip this
    }
}

func scheduleNextRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: refreshID)
    request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 3600)
    try? BGTaskScheduler.shared.submit(request)
}

// Force a run while paused in LLDB (device only):
// e -l objc -- (void)[[BGTaskScheduler sharedScheduler]
//     _simulateLaunchForTaskWithIdentifier:@"ai.goodspace.app.refresh"]
💡 Pro Tip: Reschedule inside the handler, not at app launch. A submitted request runs once, and the most common bug is an app that refreshes exactly one time after install.
Q40

You need to change the persistence schema in an app with millions of installs. How do you migrate without data loss?

AdvancedPersistence

Answer

Core Data infers a lightweight migration when the change is simple: adding or removing an attribute or entity, making an attribute optional, or adding a relationship. Renames also work if you set the renaming identifier on the new property to the old name, which people forget and then ship a migration that silently drops a column's data. Anything the inference engine cannot express, such as splitting one entity into two, deriving a value from several old fields, or transforming a relationship's cardinality, needs a mapping model and usually an NSEntityMigrationPolicy subclass where you write the transformation.

The risk in a large app is not the single-step migration you are testing, it is the user who skips six versions and jumps from a two-year-old build straight to yours, so a chained sequence of small migrations must actually compose. Staged migrations, available on recent OS versions through NSStagedMigrationManager, let you declare that chain explicitly, and SwiftData expresses the same idea with VersionedSchema types and a SchemaMigrationPlan with lightweight and custom stages. Operationally: never mutate a shipped model version in place, always create a new version; keep every historical model version in the bundle; write an automated test per upgrade path that loads a real store fixture from each supported old version and asserts row counts and a few field values after migration; and remember the store is three files with WAL journaling, so copying only the .sqlite for a fixture loses recent writes. For very large stores, do the migration off the main thread behind a progress UI, and have a fallback that rebuilds from the server rather than showing a user an app that will not open.

// SwiftData: declare each schema version, then the plan that connects them
enum SchemaV1: VersionedSchema {
    static let versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [JobV1.self] }
}

enum SchemaV2: VersionedSchema {
    static let versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [Job.self] }
}

enum JobsMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
    static var stages: [MigrationStage] { [v1ToV2] }

    static let v1ToV2 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            // de-duplicate before a new unique constraint is applied
            try context.save()
        },
        didMigrate: nil
    )
}

let container = try ModelContainer(
    for: Job.self,
    migrationPlan: JobsMigrationPlan.self
)

// Core Data equivalent knobs
// options: [NSMigratePersistentStoresAutomaticallyOption: true,
//           NSInferMappingModelAutomaticallyOption: true]

Key Points

  • Lightweight migration covers additive changes; renames need a renaming identifier
  • Complex reshaping needs a mapping model plus NSEntityMigrationPolicy
  • Test every upgrade path from every supported old version with real store fixtures
  • Migrate off the main thread, and keep a server-rebuild fallback
Q41

Set up CI/CD for an iOS app. How do you handle code signing without sharing certificates by hand?

AdvancedCI/CD & Release

Answer

The two mainstream options are fastlane on a generic CI (GitHub Actions with macOS runners, Bitrise, or self-hosted Macs) and Xcode Cloud. With fastlane, the signing answer is match: it generates the distribution certificate and provisioning profiles once, stores them encrypted in a private git repository or an S3 bucket, and every machine and CI job fetches and installs them with a single passphrase. That removes the ritual of exporting a .p12 over Slack, which is both a security problem and the reason certificates get revoked accidentally.

Authenticate to App Store Connect with an API key, the .p8 file plus key ID and issuer ID, never an Apple ID, because two-factor prompts break unattended jobs. The pipeline itself is a build number bump, xcodebuild archive with a shared scheme, xcodebuild -exportArchive with an exportOptions plist, dSYM upload to your crash reporter, then pilot or the API to push to TestFlight. On a self-hosted runner remember to create and unlock a dedicated keychain in the job, or signing fails intermittently with a UI prompt nobody can see.

Xcode Cloud removes most of this by managing signing for you and running on Apple's infrastructure, with ci_post_clone.sh style custom scripts, and it is a good fit for teams without a build engineer, but it is less flexible for complex monorepos. Either way, cache SPM and DerivedData between runs, run tests on a pinned simulator runtime so results are comparable, and use App Store Connect phased release with a kill switch behind remote config so a bad build can be stopped without waiting for review.

# fastlane/Fastfile
platform :ios do
  desc "Build and ship to TestFlight"
  lane :beta do
    setup_ci                       # creates and unlocks a temporary keychain
    app_store_connect_api_key(
      key_id: ENV["ASC_KEY_ID"],
      issuer_id: ENV["ASC_ISSUER_ID"],
      key_content: ENV["ASC_KEY_P8"],
      is_key_content_base64: true
    )
    match(type: "appstore", readonly: true)   # encrypted certs from git
    increment_build_number(build_number: latest_testflight_build_number + 1)
    build_app(
      scheme: "Jobs",
      configuration: "Release",
      export_method: "app-store",
      xcargs: "-skipPackagePluginValidation"
    )
    upload_symbols_to_crashlytics(dsym_path: lane_context[SharedValues::DSYM_OUTPUT_PATH])
    pilot(distribute_external: false, skip_waiting_for_build_processing: true)
  end
end

Key Points

  • fastlane match keeps certificates encrypted in git, readonly on CI
  • App Store Connect API key (.p8) instead of an Apple ID with 2FA
  • Create and unlock a dedicated keychain on self-hosted runners
  • Upload dSYMs in the pipeline, pin the simulator runtime, cache SPM and DerivedData
Q42

Clean builds take 12 minutes across a modularised codebase. How do you attack build time?

AdvancedBuild Systems

Answer

Measure before changing anything. Xcode's build timeline shows which targets are serialised and where the critical path is, and the Swift frontend flags -warn-long-function-bodies and -warn-long-expression-type-checking (with a threshold in milliseconds) surface the individual functions where type inference is exploding. Those are usually large SwiftUI bodies, long chained collection operations, and expressions mixing numeric literal types, and adding explicit type annotations or splitting a body into subviews frequently cuts a single function from seconds to milliseconds.

Structurally, modularisation helps only if the graph is shallow and dependencies are explicit: a module that everything imports puts itself on every critical path, so split shared code into a small stable core plus feature modules that do not depend on each other. Prefer static linking for internal modules, since dynamic frameworks cost link time in builds and dyld time at launch, and consider mergeable libraries so you keep fast incremental dynamic builds in Debug and a merged binary for Release. Ensure Release uses whole-module optimisation while Debug uses incremental, keep DEBUG_INFORMATION_FORMAT as dwarf without dSYM in Debug so you are not generating symbols on every run, and turn off Find Implicit Dependencies once the graph is explicit.

Also audit code generation: SwiftGen, Sourcery, protobuf and SwiftLint run as build phases, and an unconditional script phase with no declared inputs and outputs forces a rebuild every single time. Finally, on CI, cache DerivedData and the SPM checkouts, and split test targets across parallel simulator clones, which usually gives a bigger wall-clock win than any compiler flag.

# Find the expensive functions (add to Other Swift Flags in Debug)
-Xfrontend -warn-long-function-bodies=200
-Xfrontend -warn-long-expression-type-checking=200

# Then fix what it reports: explicit types beat inference
// Slow: the type checker explores many literal overloads
let spacing = (width - 2 * margin) / CGFloat(columns) - gutter * 0.5

// Fast: annotate and split
let available: CGFloat = width - (2 * margin)
let perColumn: CGFloat = available / CGFloat(columns)
let spacing: CGFloat = perColumn - (gutter * 0.5)

# Emit a full timing summary for the whole build
xcodebuild -workspace Jobs.xcworkspace -scheme Jobs \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -showBuildTimingSummary clean build

# Any script build phase without declared inputs/outputs reruns every build

Key Points

  • warn-long-function-bodies and warn-long-expression-type-checking find the real hotspots
  • Keep the module graph shallow; a universally imported module is on every critical path
  • Static or mergeable libraries beat many dynamic frameworks for both build and launch
  • Script phases without declared inputs and outputs rebuild every time
Q43

Your widget and notification extension keep getting killed. How do you work within iOS memory limits?

AdvancedMemory & Extensions

Answer

Extensions run under far tighter memory limits than the host app, and exceeding one is an immediate termination, not a warning. Widget extensions are commonly limited to a few tens of megabytes, notification service extensions less, and share extensions somewhat more, with the exact numbers varying by OS version and device, so the engineering rule is to treat any extension as if it has an order of magnitude less headroom than the app. The main app itself is subject to jetsam: when the system is under pressure it kills processes by footprint and priority, which shows up in crash reports as EXC_RESOURCE with a memory flavour rather than a stack trace you can read.

The dominant cost in almost every case is images. A 4000 by 3000 pixel photo costs roughly 48 MB decoded regardless of the JPEG being 2 MB on disk, so never load a full-resolution image into an extension. Downsample at decode time with CGImageSourceCreateThumbnailAtIndex and kCGImageSourceThumbnailMaxPixelSize, which decodes directly to the target size instead of decoding then scaling.

Other levers: wrap tight loops that create autoreleased Objective-C objects in an autoreleasepool so peaks do not accumulate, stream large downloads to disk with a download task rather than holding Data in memory, avoid caching decoded images in an unbounded dictionary (use NSCache, which evicts under pressure), and respond to didReceiveMemoryWarning by dropping caches. Instrument it with the Allocations and VM Tracker instruments, and use os_proc_available_memory to log remaining headroom from inside an extension so you can see how close to the edge you actually run on real devices.

import ImageIO
import os

// Decode straight to the display size: never full-resolution in an extension
func downsample(url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
    let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { return nil }

    let maxDimension = max(pointSize.width, pointSize.height) * scale
    let options = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimension,
    ] as CFDictionary

    guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else { return nil }
    return UIImage(cgImage: cgImage)
}

// Keep peaks flat in a loop that creates autoreleased objects
for batch in urls.chunked(into: 20) {
    autoreleasepool {
        batch.forEach { _ = downsample(url: $0, to: CGSize(width: 80, height: 80), scale: 3) }
    }
}

// How much headroom is actually left, from inside the extension
Logger(subsystem: "ai.goodspace.widget", category: "mem")
    .debug("available: \(os_proc_available_memory() / 1_048_576) MB")

Key Points

  • Extension limits are a fraction of the app's; exceeding one is instant termination
  • Decoded image size depends on pixel dimensions, not file size on disk
  • CGImageSourceCreateThumbnailAtIndex downsamples at decode time
  • NSCache evicts under pressure; unbounded dictionaries do not
Q44

How would you harden a payments app on iOS against a tampered device and a hostile network?

AdvancedSecurity

Answer

Assume the device can be fully controlled by an attacker and design so that a compromised client cannot cause a loss. On the network, App Transport Security already forces TLS, so the additional layer is pinning. You can pin declaratively with NSPinnedDomains in Info.plist, which supports pinning to a CA or leaf public key hash, or implement urlSession(_:didReceive:completionHandler:) and compare the SPKI hash of the server's public key against a bundled set.

Always pin at least two keys, the current one and a backup, and ship a remotely updatable pin set or a kill switch, because a certificate rotation with a single hardcoded pin bricks every installed app until users update. For device integrity, DCAppAttestService gives you a hardware-backed attestation that the request came from a genuine build of your app on genuine hardware, and DeviceCheck lets your server keep two bits of per-device state that survives reinstall, which is how teams stop one device farming unlimited signup bonuses. Jailbreak detection (checking for suspicious paths, the ability to write outside the sandbox, or a suspicious dyld image list) is worth having as a signal, but treat it as telemetry rather than a gate, since any check running on the device can be patched out.

Never ship a secret that must stay secret: strings in the binary are trivially dumped, so API keys with real privilege belong on your server. Add jailbreak and attestation results to your server-side risk score rather than blocking locally, disable screen capture on sensitive screens, clear sensitive fields on background with a privacy overlay, and log out on token reuse. Compliance work for Indian fintech, including RBI-aligned audits, will ask for exactly these controls plus evidence they are tested.

import CryptoKit
import DeviceCheck

final class PinningDelegate: NSObject, URLSessionDelegate {
    // SHA-256 of the server SPKI: current key plus a rotation backup
    private let pins: Set<String> = ["9n8Xz...=", "Kd0Qa...="]

    func urlSession(_ session: URLSession,
                    didReceive challenge: URLAuthenticationChallenge) async
                    -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        guard let trust = challenge.protectionSpace.serverTrust,
              SecTrustEvaluateWithError(trust, nil),
              let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
              let leaf = chain.first,
              let key = SecCertificateCopyKey(leaf),
              let data = SecKeyCopyExternalRepresentation(key, nil) as Data?
        else { return (.cancelAuthenticationChallenge, nil) }

        let hash = Data(SHA256.hash(data: data)).base64EncodedString()
        guard pins.contains(hash) else { return (.cancelAuthenticationChallenge, nil) }
        return (.useCredential, URLCredential(trust: trust))
    }
}

// Hardware attestation the server can verify
func attest(challenge: Data) async throws -> Data {
    let service = DCAppAttestService.shared
    guard service.isSupported else { throw AttestError.unsupported }
    let keyID = try await service.generateKey()
    return try await service.attestKey(keyID, clientDataHash: Data(SHA256.hash(data: challenge)))
}

Key Points

  • Pin at least two SPKI hashes and keep a remote kill switch for rotation
  • App Attest and DeviceCheck give server-verifiable device signals
  • Jailbreak checks are telemetry, not a gate: anything on-device can be patched
  • No privileged secrets in the binary; enforce risk decisions server side
Q45

What changes when you rebuild an existing app against the iOS 26 SDK, and how do you manage that risk?

AdvancedPlatform Updates

Answer

A large class of iOS behaviour changes are SDK-linked: they activate based on which SDK your binary was built against, not on the OS the user is running. That makes recompiling against a new SDK a behavioural change in its own right, which is why a build that only bumped Xcode can regress layout, permissions or networking. The headline example in this cycle is the Liquid Glass design introduced with iOS 26: building against that SDK opts your app into the redesigned system materials, so navigation bars, tab bars, toolbars, sheets and controls adopt the new look and scroll edge behaviour without any code change from you.

Apple provided a temporary Info.plist opt-out for teams that need a release cycle to adapt, and the right way to use it is as a scheduled migration, not a permanent setting, because compatibility keys get removed in later releases. The safe process is: adopt the new SDK on a branch, run your full screenshot and UI test suite across a device and OS matrix, walk every custom-drawn control and every view that assumed a specific bar height, blur or background, and only then decide whether to embrace new APIs like the SwiftUI glass effect modifiers or keep a neutral look. Separately from the design system, keep API adoption gated with @available and #available so a single codebase serves your full deployment target range, and prefer feature detection over version checks where the API allows it. Interviewers ask this because it separates engineers who ship apps to a large installed base from those who only develop against the newest simulator: the answer they want includes a rollout plan, a device matrix and a rollback path, not just a list of new features.

// Gate new APIs, keep one codebase for the whole deployment range
struct PrimaryButton: View {
    let title: String
    let action: () -> Void

    var body: some View {
        if #available(iOS 26, *) {
            Button(title, action: action).buttonStyle(.glass)
        } else {
            Button(title, action: action)
                .buttonStyle(.borderedProminent)
        }
    }
}

@available(iOS 17, *)
struct ObservationBackedList: View { /* newer code path */ var body: some View { EmptyView() } }

// Temporary, scheduled opt-out while you adapt custom chrome.
// Remove it in the following release; compatibility keys do not last forever.
// Info.plist:
//   <key>UIDesignRequiresCompatibility</key>
//   <true/>

// Runtime check for the OS the user is actually on
if ProcessInfo.processInfo.isOperatingSystemAtLeast(.init(majorVersion: 26, minorVersion: 0, patchVersion: 0)) {
    Analytics.tag("os.modern")
}

Key Points

  • Many behaviour changes are SDK-linked: recompiling is itself a risky change
  • iOS 26 Liquid Glass applies automatically to apps built with that SDK
  • The Info.plist compatibility opt-out is a scheduled migration, not a permanent answer
  • Gate new APIs with @available and #available, and test a real device and OS matrix

Companies Hiring iOS

Swiggy
Zomato
Flipkart
PhonePe
CRED
Dream11
Zerodha
Meesho

Salary Insights

Average in India
₹8-28 LPA

Frequently Asked Questions

What does an iOS developer earn in India in 2026?

Roughly ₹8-28 LPA depending on level and employer. Freshers at service companies and smaller product firms usually start at ₹4-8 LPA, engineers with two to four years of solid Swift and SwiftUI land ₹12-20 LPA, and senior or lead iOS engineers at consumer product companies such as Swiggy, Zomato, Flipkart, PhonePe, CRED, Dream11 and Zerodha regularly clear ₹28 LPA in total compensation, higher with stock. iOS pays a small premium over Android at the same level in India because the supply of strong Swift Concurrency and performance-tuning engineers is thinner. Fintech and trading apps pay the most because a crash or a memory bug there is a money and compliance problem, not just a bad review.

How long does it take to prepare for iOS interviews?

If you already ship iOS code, four to six weeks of focused evenings is realistic: one week consolidating Swift value semantics, optionals and ARC, two weeks on Swift Concurrency and SwiftUI state and identity, one week on UIKit internals such as cell reuse, layout and the view controller lifecycle, and one week on production topics like crash triage, launch time and app size. If you are switching from Android or web, plan for three to four months, because the language and the framework are only half the job and the interview will test Instruments, memory behaviour and App Store realities that no tutorial covers. Building and actually shipping one app to TestFlight teaches more than any question bank, because signing, privacy manifests and review feedback are all things interviewers ask about.

What is the difference between fresher and experienced iOS interviews?

Fresher rounds concentrate on Swift language mechanics (structs versus classes, optionals, closures, protocols), basic UIKit or SwiftUI screen building, and a small take-home that fetches JSON and shows a list. Getting the async image loading and the cell reuse correct in that take-home is what separates offers from rejections. Experienced rounds spend most of their time on concurrency and data-race safety, architecture and testability, and production behaviour: what your crash-free rate was, how you found a memory leak, why launch time regressed, how you shipped a schema migration to millions of installs. From roughly three years of experience the interview becomes a conversation about decisions you made and their consequences, so keep specific numbers from your own releases ready.

Is native iOS still worth learning in 2026 with Flutter and React Native around?

Yes, and the market has stabilised into a clear split. Cross-platform frameworks own content-heavy and CRUD-style apps where one team shipping to both platforms matters more than platform polish. Native iOS owns the categories where performance, security, deep OS integration and design quality decide the product: payments and trading, streaming, camera and on-device machine learning, widgets, Live Activities and watchOS. Those are also the highest-paying segments in India. A pragmatic point in favour of native skills is that most large cross-platform apps still need engineers who can write platform code for plugins, debug a native crash and handle App Store review, so Swift knowledge remains valuable even inside a Flutter or React Native team.

Should I learn SwiftUI or UIKit first?

Learn SwiftUI first, then enough UIKit to be useful. Almost all new screens in 2026 are written in SwiftUI, and it is faster to become productive with, so it gets you shipping and interviewing sooner. But every company with an app older than a few years has a large UIKit codebase, and interviewers still ask about the view controller lifecycle, Auto Layout priorities, cell reuse and diffable data sources because that is the code you will maintain in your first year. The practical target is to build confidently in SwiftUI, read and modify UIKit comfortably, and be able to bridge in both directions with UIHostingController and UIViewRepresentable, since real projects mix the two on the same screen.

Do I need Objective-C to get an iOS job in India?

Not to get hired, but reading it is a genuine advantage. New code is Swift, and no product company will ask you to write Objective-C from scratch in an interview. However, service companies and older enterprise codebases still maintain large Objective-C modules, many popular SDKs are Objective-C under the hood, and interpreting a crash report or a swizzled method often means reading Objective-C stack frames. Spending a weekend learning the syntax, the bridging header, nullability annotations and how ARC differs there is a high-return investment, and mentioning that you can debug through an Objective-C frame is a small but real differentiator in service-company and fintech interviews.

Introduction

iOS engineering in 2026 no longer looks like the UIKit-and-delegates world most tutorials still teach. Swift 6 turned data-race safety into a compile-time property, the Observation framework retired most ObservableObject boilerplate, and SwiftUI is now the default for new screens at almost every Indian consumer app. At the same time the UIKit code your team inherited is still the code earning revenue, so a real interview tests both in the same hour: where viewIsAppearing sits in the view controller lifecycle, and why the compiler refuses to let a non-Sendable model cross an actor boundary. Hiring bars moved well past building a table view.

Interview loops at Swiggy, Zomato, PhonePe, CRED, Dream11 and Flipkart usually run four rounds: a Swift language round on value semantics, optionals and ARC, a UI round where you build a scrolling screen and defend layout and reuse decisions, a concurrency and architecture round that quickly becomes a discussion of actors, MainActor isolation and testability, and a production round on crash triage, launch time, app size and App Store review. Service companies and consultancies weight UIKit and Objective-C interop higher, because their client codebases are older. Product companies weight SwiftUI, Swift Concurrency and instrumentation far more heavily.

This set works through 45 iOS interview questions asked in 2026, ordered from basic to advanced, with code you can paste into a playground or an Xcode project and run. Most questions carry a worked example, and every answer includes the production failure mode the interviewer is really fishing for: the retain cycle that only leaks on a second push, the cell that shows the wrong image after a fast scroll, the widget that gets jetsammed, the build that gets rejected for a missing required-reason API entry. Work through the basics, then spend your real preparation time in the concurrency, performance and release sections.

Ready to practice iOS interviews?

Don't just read, practice these iOS questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview