Engineering an SDK at scale: the four contracts
Building a library that runs inside other teams’ processes, across many markets, on millions of devices - the four contracts every SDK signs, the numbers measured rather than repeated, and the tests that prove each one.
Building a library that runs inside other teams’ processes, across many markets, on millions of devices - the four contracts every SDK signs, the numbers measured rather than repeated, and the tests that prove each one.
A defect in an application is handled by the team that shipped it, on that team’s release schedule. A defect in an SDK surfaces in every application that embeds it, in each consumer’s crash reporting, on each consumer’s release schedule, in every market those consumers serve. The fix reaches end users only after each consuming team has picked it up, rebuilt, and passed review. That asymmetry shapes every engineering decision on an SDK team, and it changes what correctness means for the code involved.
The examples in this article are Swift, because that is the platform on which we measured them. The principles are not platform-specific. Any team that ships a library into a process it does not own - a payments module embedded in a dozen banking applications, an analytics layer inside a retailer’s storefront, a mapping component inside a delivery application - operates under the same four contracts, whether or not they have been written down.
The material comes from an internal study we ran to make those contracts precise: two Swift packages, 130 tests, every snippet compiled on Swift 6.3, and every load-bearing claim either checked against Apple’s documentation or measured on our own hardware. Where a figure is measured, we state the workload. Where a figure is an estimate, we say so. Where a scenario is constructed rather than reported, we say that too. No consuming application, partner or market is identified.
The boundary that cannot move
Every design discussion about an SDK eventually converges on one element: the public API. Not because the internals are simple, but because the internals are the only part the SDK team is free to change after release.
Four entry points, one configuration object, and a documented threading guarantee placed on the boundary itself. The documentation comment is part of the API. Everything to the right of the line can change in a patch release; anything on the line that changes is a migration for every consuming team.
Five constraints follow from executing inside a process the SDK does not own:
- Never crash the host. The host’s crash-free rate includes the SDK’s crashes. They appear in the host’s crash reporting, against the SDK’s symbols, and the host team reads them first.
- Never block the host’s threads. The calling thread is not known in advance, and no assumption about it will hold across consumers.
- Never assume the host’s lifecycle. Launch order, backgrounding behaviour, and the other components sharing the process are all outside the SDK’s control.
- Never require a restructure to adopt. If integration obliges a consumer to rewrite their networking layer, the design has failed at the boundary.
- Every public symbol is permanent. Anything behind the API can be rewritten. Nothing in front of it can be changed without cost to every consumer.
The first three are concurrency and lifecycle constraints: “never crash” is largely a data-race question, “never block” is a main-thread-budget question, and “never assume the lifecycle” is a question of who owns each task and who cancels it. The last two are constraints on the shape and permanence of the API. Concurrency dominates the list because it is the class of failure that reaches millions of devices through code the SDK team does not control.
The rest of this article is organised as four contracts, and each follows from those constraints. Thread safety on the boundary follows from the first. Main-thread and lifecycle discipline follow from the second and third. Treating the server as the source of truth follows from running many clients against one backend. Shipping defects as patch releases follows from the last two. For each contract we describe the mechanism, the design decision it forces, and the test that proves it holds.
Contract one: thread safety is part of the API
Consider first the class of defect that does not crash.
An unsynchronised counter rarely crashes. In this recorded run it discarded more than half of the increments and reported the remainder as the total, with no crash and no diagnostic; the amount lost varies from run to run. The Mutex implementation records exactly 400,000 on every run, and Swift 6 permits it to conform to Sendable without @unchecked.
A data race on an unsynchronised variable written from concurrent threads rarely faults. In the recorded run above it discarded more than half of the increments and reported a total, with no crash and no diagnostic. Embedded in an analytics SDK, that behaviour produces no visible symptom in the host for months, until a consumer reports that event counts fail to reconcile.
The conclusion is not simply “use a lock”. It is that an unstated thread-safety guarantee is one that consumers will assume and the SDK team will never test. If the documentation does not specify which members may be called from which threads, consumers will call setters from whichever thread is convenient - normal behaviour for an application. With no published guarantee there is no specification to test against, and when the integration fails neither party has violated a contract. Code review catches incorrect code. It does not catch a contract that was never written.
The guarantee therefore belongs on the public boundary, as a commitment:
| /// Thread-safe. All members may be called from any thread. public final class Analytics { ... } |
It is then enforced: every public entry point exercised from multiple threads under Thread Sanitizer in continuous integration, so that a regression fails the build rather than a consumer’s release.
The decision the API shape forces
Take a concrete case. A public cache type has shipped with a synchronous getter. Hosts read it on the main thread while configuring a cell, and one host calls it from Objective-C. A data race is found inside it. Which isolation mechanism should the fix use?
The common instinct is an actor. In an application, that is frequently the correct choice. In an SDK, for an existing synchronous API, it is wrong, and the reason is the boundary described above.
An actor-isolated method is async to every caller outside the actor. Adopting an actor converts each existing call site to an await, and the await propagates outward through the consumer’s code until it reaches something that cannot be asynchronous. For a library with ten consuming applications, that is ten migrations to absorb one internal change. Under strict semantic versioning it is a major release, incurred to fix a data race. Contract four returns to that cost.
The decision therefore turns on one question, asked before any implementation is written: is this an existing API that must be satisfied as written, with live call sites that would break, or a new API whose shape is still open? For a new asynchronous API, an actor is a reasonable choice, and contract two uses one. For an existing synchronous API, the actor is excluded before performance is considered.
Two mechanisms keep the read synchronous: a lock, or a serial dispatch queue read with sync. We use the lock - Mutex from the Synchronization module on iOS 18 and later, OSAllocatedUnfairLock on iOS 16 and 17 - for a reason that has nothing to do with speed:
import Synchronization
public final class LockedCache: Sendable {
private let entries = Mutex<[String: Value]>([:])
public func setValue(_ value: Value?, forKey key: String) {
entries.withLock { $0[key] = value }
}
public func getValue(forKey key: String) -> Value? {
entries.withLock { $0[key] }
}
}
Mutex holds the protected state inside the lock rather than alongside it. The compiler can then verify that the state is only reached through withLock, which eliminates the category of bug in which one code path forgets to synchronise, and it allows the type to conform to Sendable without @unchecked. A dispatch queue offers neither property. State guarded by a queue is guarded by convention: the compiler cannot see that a queue.sync wraps every access, and the type can only be marked Sendable with @unchecked, an assertion that every method added later can silently violate. In an application that risk is bounded. In a library that many teams extend over years, compiler enforcement is worth more than any benchmark.Three practices accompany any lock-based design. Use withLock rather than a manual lock() and unlock() pair, because a manual pair leaks the lock on an early return and produces a hang rather than a crash. Never invoke external code while holding the lock - delegates, completion handlers, notifications - because re-entry into the same lock deadlocks. And treat the choice of primitive as a deployment-target decision: Mutex where iOS 18 is the minimum, OSAllocatedUnfairLock below it. They are the same abstraction, one generation apart.
Measuring rather than repeating
The claim that an unfair lock outperforms a dispatch queue is widely repeated, and it appeared in one of our own code comments before anyone had measured it. We placed four mechanisms behind one contract - a rate coalescer with an async receive and flush - and drove each with thirty concurrent tasks, each awaiting sixty thousand receives in total.
Thirty concurrent tasks awaiting receive, sixty thousand times in total. The two queues were indistinguishable, and the lock was slower than both because the queue implementations accept a write with async and return before it executes, while the lock performs it first. The widely quoted microbenchmark that places an unfair lock an order of magnitude ahead measures ten thousand uncontended synchronous increments. Both figures are real; they measure different workloads, and the workload belongs alongside the number.
Two results contradicted the received wisdom. A concurrent queue with barrier writes was not slower than a serial queue on this write-heavy workload, because every operation is a barrier and the concurrent queue degenerates to a serial one. And the lock was slower than both queues. The reason is in the harness, and it matters more than the ranking. The queue implementations accept a write with queue.async and return before it executes, so thirty producers never wait on each other. The Mutex implementation performs the write before returning, so producers contend. The queues win this measurement by deferring the work. On the synchronous getter of the previous section that option does not exist: a queue would have to use sync, wait its turn, and hop threads, which is the case the frequently cited microbenchmark measures when it places os_unfair_lock an order of magnitude ahead over ten thousand uncontended increments. Both figures are real; they measure different workloads.
The durable lesson is not the millisecond values, which belong to one machine. It is that a design note should state the trade-off that was measured, with its workload, rather than the one that was read. A note that reads “the lock, for compiler-verified isolation behind a synchronous API; under thirty async producers it measured 1.6 to 1.8 times slower than a serial queue” is verifiable, and a reviewer can reproduce it.
Contract two: never block the host’s threads
The second contract concerns the one thread every component in the process shares, and the task lifetimes the SDK creates inside a process whose lifecycle it does not control.
Apple’s documentation is direct on the first point. The main dispatch queue, the main operation queue, and the main actor are all clients of the main run loop. A long-running work item submitted through any of them delays every other client, including the user’s pending touch event. Apple defines a hang as a delay of “about 50 ms to 100 ms or longer”, and a hitch as a single missed refresh interval: 16.67 ms at 60 Hz, 8.33 ms at 120 Hz.
An SDK cannot control how much work the host already schedules on the main thread. It can only control how often it adds to that work, and how much it adds each time. The relevant question for an SDK team is therefore quantitative: how many times per frame does the SDK cross onto the main actor, and what does each crossing cost?
The upper bar grows with the product of pairs and update rate. The lower bar does not move, because a per-key buffer flushed once per frame costs one crossing regardless of how many producers feed it. Below, the measured cost of a crossing: ten thousand items delivered one hop each against the same items in one batched hop. The thirty-pairs-at-twenty-updates scenario is constructed on real constraints; it is not attributed to any production system.
Thirty currency pairs updating twenty times per second produce six hundred crossings per second. Against a 60 Hz display, that is ten crossings per frame. Each pair itself updates only once every three frames, so at this rate no value is overwritten before the display shows it. The waste is not in the values; it is in the transport. Ten separate hops onto the main actor per frame deliver what one hop could carry, and the display renders the ten cells together in any case.
The cost of a hop is the second number in the figure, and we measured it: ten thousand items delivered to the main actor one crossing each took between 76 and 99 ms across three runs; the same ten thousand items in one batched crossing took under a millisecond. The ratio, 91 to 117 times, belongs to the mechanism. The milliseconds belong to one machine.
The remedy is a per-key buffer flushed once per frame. It collapses the ten hops into one, and, when a feed does exceed the refresh rate - a two-hundred-hertz sensor, or a reconnect replaying a buffered second of ticks in a burst - its latest-wins rule discards the intermediate values that no frame could have shown:
public struct RateTick: Sendable {
public let pair: String
public let sequence: Int
public let price: Decimal}
public actor RateCoalescer {
private var pending: [String: RateTick] = [:]
private var newestSequence: [String: Int] = [:]
private let delivery: any MainDelivering<[RateTick]> // introduced below
public init(delivery: any MainDelivering<[RateTick]>) {
self.delivery = delivery
}
/// Runs hundreds of times a second, so it stays cheap. No crossing, no formatting.
public func receive(_ tick: RateTick) {
guard tick.sequence > (newestSequence[tick.pair] ?? .min) else { return }
newestSequence[tick.pair] = tick.sequence
pending[tick.pair] = tick
}
public func flush() async {
guard !pending.isEmpty else { return }
// Snapshot and clear BEFORE the await. Every `await` is a point where this
// actor can be re-entered, and a `receive` arriving mid-flush must land in
// the next batch rather than be erased by a `removeAll` that runs after
// the suspension.
let batch = pending.values.sorted { $0.pair < $1.pair }
pending.removeAll(keepingCapacity: true)
await delivery.deliver(batch)
}
}
Two details of that type connect to the other contracts. It is an actor because its API is new and asynchronous by design; contract one excludes converting an existing synchronous API to an actor, not choosing one for a new API. And receive compares a sequence number carried by the tick rather than trusting arrival order, because ticks from a network can arrive out of order. Contract three explains where that number comes from and why the client must not invent it.
The comment inside flush carries the important constraint. If removeAll is moved below the await, the code remains free of data races and compiles cleanly under Swift 6 strict concurrency, and it is incorrect: any tick received while the actor is suspended in deliver is discarded when the actor resumes. That is a race condition rather than a data race, a distinction contract three states precisely. State must be mutated before the suspension point, not after it.
Making the main thread injectable
“The interface froze” is not a proposition a unit test can evaluate: there is no display and no user in a test process. What can be tested is the cause of the freeze, provided delivery to the main thread is placed behind a protocol:
public protocol MainDelivering: Sendable {
associatedtype Value: Sendable
func deliver(_ value: Value) async
}
Networking is routinely abstracted behind a protocol; main-thread delivery rarely is, although it is the delivery, not the network, that stalls the interface. With a counting test double behind this seam, the observation “the list stutters” becomes the assertion deliveryCount == 600, and the fix becomes deliveryCount == 1. Making the expensive operation injectable so that its cost becomes a number is the general technique, and it is what distinguishes a design that is demonstrably better from one that is presumed to be.
The payload determines the policy
The coalescer’s latest-wins rule is correct for the data it carries. Applied to a different payload, the same rule becomes a defect. A price the user never saw is a price that no longer exists. A transaction the user never saw is a missing transaction, and in a financial product that is a support case rather than a dropped frame.
That distinction changes the data structure from a dictionary to a FIFO queue, the delivery policy from latest-wins to conservation, and the assertions from “how few crossings” to “did every row arrive, in order, without exceeding the frame budget”. It also introduces a second budget. Delivering five thousand rows in a single crossing is one crossing and one main-thread work item whose duration is the per-row cost multiplied by five thousand. At eighty microseconds per row - an estimate, not a measurement, and a modest one for a configured cell - that is 400 ms, a hang by Apple’s definition. Fewer crossings and smaller crossings are distinct goals, and an SDK feeding a host’s list view must satisfy both.
| Payload | Policy | Rationale |
|---|---|---|
| Price, FX rate, sensor value | Latest-wins, discard intermediates | A stale price is worse than no price |
| Transaction, ledger entry | Queue everything, bounded flush | A missing row is a correctness failure |
| Analytics event | Bounded buffer, drop oldest, count it | Partial loss is acceptable; blocking the host is not |
| Auth state change | Deliver immediately, no batching | Latency matters more than smoothness |
The third row is the one that inverts most easily. An analytics library that blocks the host process to avoid losing an event has reversed its own contract. The owner of the process determines the policy.
Owning cancellation
The third constraint, never assume the host’s lifecycle, becomes concrete as a rule about task ownership. Every task the SDK starts has an owner inside the SDK that cancels it, tied to something the SDK can observe: a subscription ending, a configure being superseded, a host explicitly stopping a feed. A task left to run until the process dies is a task that will one day resume against a host screen that no longer exists.
Two details separate cancellation that works in a test from cancellation that works in production. Both surfaced in our own test suite before they could surface in a consumer’s.
A cancelled URLSession request throws URLError.cancelled, not CancellationError. A catch is CancellationError clause misses the case that actually occurs, and the consumer’s user sees a failure banner after navigating away. The condition should be normalised once, at the SDK’s networking boundary, so that no caller has to recognise two spellings of one event.
try? await Task.sleep(...) swallows cancellation. The try? discards the CancellationError and execution continues as though the sleep completed. In our suite this meant a hang could not be interrupted by the time limit intended to catch it. A bounded wait that fails identifies which assertion broke; an unbounded wait hangs and identifies nothing.
Contract three: the server holds the truth
The third contract concerns correctness, and it is the area in which “make it an actor” is the most common inadequate answer.
An account screen combines two paths. When the user switches account, the client requests that account’s balance. While the screen is visible, the server also pushes balance events over a stream. The displayed balance occasionally moves backwards. Nothing in the failing code is a data race: it is Sendable-clean, it passes Swift 6 strict checking, and Thread Sanitizer reports nothing. Two balance events left the server in order and overtook each other in transit. The older one arrived second and overwrote the newer value.
The distinction is worth stating precisely. A data race is unsynchronised access to shared memory from more than one thread. A race condition is a dependency of the result on the order in which events happen to occur. Actors remove the first class entirely. Only the design removes the second.
Serialising the updates does not order them; they were already serialised, in the wrong order, by the time they reached the client. Each path needs its own defence, and the two paths together need three.
Ordering by arrival time is incorrect; ordering by server-assigned sequence is correct. Timestamps are not a substitute: client and server clocks disagree, and Date() can move backwards across a clock correction. A sequence number is a total order by construction.
The three defences are complementary rather than alternative. The first two protect the request path. Cancelling the previous request when the account switches avoids wasted work, but cancellation is cooperative, so a request that completed immediately before the cancel has already resumed its continuation. A generation token closes that window by checking at the point of application rather than the point of departure. Neither says anything about the stream: they order the client’s own requests, not the server’s events. Only a server-assigned sequence number per channel, with a rule that no update older than the one already applied may modify state, survives network reordering, a reconnection replaying a buffer, or two sockets racing during a failover. If the payload carries no sequence, that is a gap to close with the backend team rather than to work around on the client.
Forcing the interleaving
The property that makes an ordering defect difficult in production is the property that makes it straightforward in a test: the interleaving can be forced rather than awaited.
@Test func lateBalanceUpdateCannotMoveTheBalanceBackwards() async {
let reducer = AccountStateReducer()
await reducer.apply(.balance(sequence: 2, value: 250))
await reducer.apply(.balance(sequence: 1, value: 100)) // overtaken in flight
#expect(await reducer.state.balance == 250) // late arrival rejected
#expect(await reducer.rejectedAsStale == 1) // and counted
}
A stress test establishes that a race exists. Only an interleaving written out by hand reproduces a specific one, and only a reproduced defect stays fixed. The second assertion is deliberate: a rejected update is normal, but a high rejection rate indicates heavy reordering on the network, and that should be observable rather than silent.
When correctness is money
For an SDK that moves money on behalf of many applications, the ordering discipline extends to the entire operation.
Each state has a defined set of legal transitions, which is what distinguishes a state machine from a collection of flags. Two properties matter most: the authentication step is persisted, because a second-factor flow can leave the application and return after process death; and a timeout transitions to unknown rather than failed. Treating a timeout as a failure is the most common path to a duplicate charge.
Two properties of that state machine carry the design. The idempotency key is generated once per logical transfer and reused on every retry; generating it per attempt means that a retry after a lost response charges the user twice. And a timeout transitions to unknown, not to failed. A timeout establishes only that the response did not arrive. It carries no information about whether the transfer was executed. The client’s responsibility is to reconcile with the server, not to infer an outcome.
The same reasoning settles a question that appears to be about interface design and is not. Optimistic interfaces are appropriate for low-stakes actions and inappropriate for transfers: the pending state is shown until the server confirms, and the submit control is disabled on tap rather than on response.
It also settles what the SDK is permitted to log. State transitions are recorded; values are not. A balance in a crash report is a compliance incident, not diagnostic data, and the SDK team does not control which crash-reporting vendor each host uploads to.
Contract four: defects ship as patch releases
The fourth contract converts every decision above into a versioning decision.
Semantic versioning, applied strictly, has three rules. A major release contains a source-breaking change and costs every consumer a migration. A minor release is additive and backward compatible. A patch release is a fix with no change to the public API. The consequence for an SDK team is that its own defects must ship as patch releases: fixing a bug in the library must never require a consumer to migrate. That is the cost contract one deferred, and the rule is the same one that excluded the actor there.
What counts as breaking depends on how the library is distributed, and that decision has to be made before any API rule below can be applied. A binary XCFramework built with library evolution enabled has a stable interface across compiler versions: consumers must handle unknown cases of a public enum with @unknown default, and @frozen is the explicit promise that no case will ever be added. A source package has none of that. Consumers compile it themselves, every public enum is exhaustively switchable, and adding any case is a source-breaking change regardless of annotation. For a library consumed by many teams on their own release schedules we ship binary with library evolution enabled, and the classifier below assumes that.
Verdicts assume a binary framework built with library evolution enabled; in a source package every public enum is exhaustively switchable and any new case is a major. Applied strictly, semantic versioning reduces to one rule of stewardship: fixing a defect in the library must never require a consumer to migrate.
Several API decisions follow from taking the patch rule seriously:
- One configuration object rather than a parameter list. Adding a field with a default is additive. Adding a parameter is a breaking change.
- Default to internal. Symbols become public deliberately, individually, with a documented reason. Four entry points is a maintainable surface; forty is a support commitment made without review.
- public and final rather than open. A consumer’s subclass becomes part of the compatibility surface.
- @frozen only with strong justification. It is a commitment never to add a case. A non-frozen enum, in a library built for distribution, obliges consumers to handle @unknown default, which is what permits new cases in a minor release.
- An owned error surface. A public error type under the SDK’s control is preferable to exposing URLError or DecodingError, because those are implementation details and exposing them makes a change of networking stack a breaking change. Typed throws places the error type in the signature; changing that type is a major release, so it is adopted only where the error type is genuinely stable.
- Deprecation before removal. At least one minor release of overlap, with @available(*, deprecated, renamed:) pointing to the replacement. Removal occurs in the next major release, accompanied by a migration guide that explains the reason as well as the change.
- No third-party dependencies. A dependency of the SDK becomes the consumer’s version conflict, binary size, and supply-chain audit finding. In regulated environments this is frequently a requirement rather than a preference.
- Explicit configure, with no work at load time. An Objective-C +load, a C constructor, or a static initialiser in a linked C++ dependency runs before main, for every user of the host, including those who never reach the SDK’s feature. Moving that work to a background queue at launch does not remove it; it adds contention during the busiest phase of the host’s lifecycle.
- Deliberate limits on configurability. Every exposed setting is a permanent support commitment. Declining to expose the retry policy because the SDK owns its correctness is a defensible position; exposing it is a commitment to support every value a consumer might choose.
One further decision is specific to the current toolchain. Swift 6.2 made default actor isolation a per-target build setting, and new application projects default to MainActor. For an application, that removes a large number of annotations and matches how most interface code behaves. For a library, enabling it makes every unannotated public declaration @MainActor, and a library that performs background work then forces a main-actor hop on every caller, a cost the consumer did not choose and cannot remove. Our position is that application targets may adopt the default and library targets keep explicit isolation.
Rollout as part of the design
A release does not reach every market simultaneously. Where each market ships its own application, the release reaches one pilot application first, and the pilot is where a defect that would otherwise have been an incident is contained. Crash attribution across hosts must be built, because each consumer’s crash reporting sees only its own application, and no view exists of the SDK’s crash rate across all of them unless the SDK team creates one. And the SDK carries a kill switch, so that a consumer can disable a subsystem through configuration rather than a new build. Consumer release cycles may run to weeks, and in a regulated environment the rollback path cannot depend on submitting a binary and waiting for review.
Reviewing the design
When the system under review is an account SDK consumed by several regional applications - the balance and transfer flows of contract three, packaged for hosts - we apply the same nine-step framework in every review. Where reviews tend to stop is itself informative.
- Requirements and constraints
Who consumes it, on which platforms, whether release trains are shared, and which regulation applies in each market. Established by asking, not assumed. - Data flow
Drawn explicitly. Host call to public API to request or stream to reducer to persistence to main-thread delivery. Every arrow is a decision. - Components and protocols
The public boundary is the design. Name what sits on the boundary and what sits behind it. - Concurrency model
What is isolated, what is locked, what may be called from any thread, and who owns each task lifetime.
output a published threat-safety contract - Persistence and cache policy
What survives loss of connectivity and process death, and what is rebuilt from the server. What is authoritative, what is a cache of it, and for how long. - Failure modes
Offline, partial response, duplicate delivery, reconnect replaying a buffer, process death mid-transfer. The failure path is drawn, not narrated.
Most reviews end up one step before this - Testing
Interleavings written by hand, an injected clock, and Thread Sanitizer in CI for the class of defect example-based tests cannot express. - Observability
Crash attribution across hosts, adoption per version, integration time, and the count of updates rejected as stale.
early signal stale-rejection count - Trade-offs and deferred scope
What was deliberately not built, and the reasoning. A design without stated omissions has not been prioritised.
required state omissions
Many designs complete the first three steps and stop. Component diagrams are necessary but not sufficient. The review earns its value when it describes what happens when a transfer times out mid-flight, what would be logged so that the failure is visible without logging the amount, and what would be deferred if the schedule were halved.
Five dimensions determine whether a diagram represents a design or a demonstration: components (are they the right ones, at the right granularity), direction (do the arrows encode real dependencies), concurrency (what runs where, under which isolation), failure (what breaks, and what the host observes), and authority (which element is the source of truth). Most diagrams address the first two. The remaining three are the ones most often omitted, which is precisely why they carry the most information. Isolation is marked on each component. The retry and reconciliation path is drawn. Exactly one element is marked as authoritative, and for this system it is the server.
A review closes with the deferred scope stated explicitly. For this design, under a halved schedule: the offline write queue - transfers composed without connectivity and submitted later - would be deferred, and offline operation made read-only, because reconciliation of queued writes is where the risk concentrates. Sequence ordering and idempotency keys would be retained, because they are correctness requirements rather than features. The count of updates rejected as stale would be instrumented from the first release, because it is the earliest signal that the ordering logic is wrong.
Rollout as part of the design
A release does not reach every market simultaneously. Where each market ships its own application, the release reaches one pilot application first, and the pilot is where a defect that would otherwise have been an incident is contained. Crash attribution across hosts must be built, because each consumer’s crash reporting sees only its own application, and no view exists of the SDK’s crash rate across all of them unless the SDK team creates one. And the SDK carries a kill switch, so that a consumer can disable a subsystem through configuration rather than a new build. Consumer release cycles may run to weeks, and in a regulated environment the rollback path cannot depend on submitting a binary and waiting for review.
Summary
For a team whose code executes inside applications it does not own:
- The five constraints are written down, and the public boundary is treated as permanent.
- A thread-safety contract is published on that boundary and enforced in CI under Thread Sanitizer.
- The choice between actor and lock is derived from the shape of the API, and any performance claim in the design note carries the workload it was measured under.
- Main-thread cost is expressed as arithmetic for every feed delivered: crossings per second, crossings per frame, and the measured cost of one crossing.
- Every task the SDK starts has an owner that cancels it, and cancellation is normalised once at the networking boundary.
- The distinction between data race and race condition is understood, and ordering is protected by regression tests that force the interleaving.
- Staleness is handled by all three defences, with server sequence recognised as the one that protects financial data.
- Transfers are modelled as a state machine with an explicit unknown state and an idempotency key generated once.
- The distribution model is decided first, semantic versioning is applied strictly against it, and library targets keep explicit isolation.
- Design reviews reach failure modes, observability and deferred scope as a matter of routine.
None of these practices is novel. Together they are the difference between a library that is documented as safe and one that has been verified to be.
Resources
- Understanding hangs in your app - Apple Developer Documentation
- Understanding user interface responsiveness - Apple Developer Documentation
- Swift concurrency: Behind the scenes - WWDC21
- Mutex - Synchronization framework
- Sendable - Swift standard library
- Updating an app to use strict concurrency
- Binary frameworks in Swift - WWDC19, on library evolution and what a stable interface commits to
- URLError and CancellationError, the two spellings of a cancelled request
- Swift Testing