Ask how big a Handoff payload should be in 2026 and the current Apple documentation will not give you a number. The live NSUserActivity reference describes userInfo as the state needed to continue an activity on another device, lists the types that dictionary may hold, and never says how large it may be. Foundation still ships NSUserActivityHandoffUserInfoTooLargeError, documented only as "The user info dictionary was too large to receive." The useful question has moved. The same activity can now name the entity on screen for Siri and Apple Intelligence. What you put in it is a resume hint for your other devices and a cue for the system. Send an identifier, and let the receiving side load the content.
The current reference publishes no size
Fetched on 11 October 2026, the userInfo page says this, and nothing about a limit:
A dictionary containing app-specific state information needed to continue an activity on another device.
Apple, NSUserActivity.userInfo (documentation fetched 2026-10-11)
The same page lists the permitted value types. Each key and value must be an NSArray, NSData, NSDate, NSDictionary, NSNull, NSNumber, NSSet, NSString, or NSURL. The Swift signature is still [AnyHashable: Any]?, so the compiler will accept a value the transport will not carry. The list used to be easy to miss, because for years it lived on an archived guide and the reference page did not repeat it. It is on the current page now. A size is not.
The class page, fetched the same day, agrees by omission. It tells you to put a URL in webpageURL, to name a specific piece of content with targetContentIdentifier, appEntityIdentifier, or externalMediaContentIdentifier, and to place any other app-specific data in userInfo. Search that page for a kilobyte figure and there is nothing to find.
Foundation's dedicated failure for this case is just as quiet about the number. NSUserActivityHandoffUserInfoTooLargeError is "The user info dictionary was too large to receive." Three siblings sit on the same page, each one line long: NSUserActivityConnectionUnavailableError, "The user activity couldn't be continued because a required connection wasn't available"; NSUserActivityHandoffFailedError, "The data for the user activity wasn't available"; and NSUserActivityRemoteApplicationTimedOutError, "The remote application failed to send data within the specified time."
Three of the four describe transport conditions you cannot design away: no connection, no data, too slow. The fourth describes something you control completely, and it is the only one for which a number would be actionable. Apple does not publish that number on the current reference. A committed figure would be a compatibility promise across every OS version, device pair and radio, while a failure code costs nothing to keep. The practical reading is unchanged: the system will tell you at runtime, on someone's device, that you were over. It will not tell you in advance what over means.
The same activity now feeds Siri and Apple Intelligence
A Handoff payload used to be a private note between two of your own processes. That is no longer the whole story. The class overview says system features like Siri and Apple Intelligence use contextual information from your app's interface to improve their responses, and it tells you to assign app entities to your views, or to put one entity on the user activity. The property that does the second job is appEntityIdentifier:
By associating an app entity with a user activity, you make the entity available to Siri and Apple Intelligence.
Apple, NSUserActivity.appEntityIdentifier (documentation fetched 2026-10-11)
Apple's guide Providing contextual cues to Apple Intelligence and Siri is the on-screen half of the same mechanism. Your interface holds data the system cannot read, because that data is private to your app, until you annotate a view with an app entity. When the screen shows one entity, and that screen is also something the person can continue on another device, Apple's own advice is to deliver the entity on the user activity. A user activity stores a single optional entity in appEntityIdentifier, and that is the case Handoff was already modelling: one activity, one thing the person was doing.
The property's machine-readable header, on that same fetch, marks it available from iOS 18.2, iPadOS 18.2, Mac Catalyst 18.2, macOS 15.2, tvOS 18.2, visionOS 2.2 and watchOS 11.2. Set it to nil to clear the association. In SwiftUI the usual place is the userActivity modifier, assigning EntityIdentifier(for:) while you configure the activity. In UIKit and AppKit the activity goes on the responder's userActivity.
Send an identifier, and let the other side load the content
Read the class overview as a filing rule. A web address goes in webpageURL. The identity of a specific piece of content goes in targetContentIdentifier, appEntityIdentifier, or externalMediaContentIdentifier. userInfo is for whatever app-specific state is still required to continue, and that state should be an identifier too: which document, which section, which revision. The receiving app fetches what the signed-in person is allowed to see. The dictionary is a pointer, not a copy of the thing.
The privacy consequence and the AI consequence are the same decision, looked at from two sides. Handoff's job is to deliver the activity, dictionary included, to the person's other devices. appEntityIdentifier's job, in the sentence quoted above, is to make that entity available to Siri and Apple Intelligence. A further, separate step exports content another app can actually use: conform the entity to Transferable, and for a person, a place or a contact provide an IntentValueRepresentation. That export is how a representation leaves your process. It is not a side effect of setting the identifier, and it is not a reason to tuck the document body, the message text or a customer record into userInfo because the identifier felt thin. Part 6 is the rest of this boundary: public indexing, the eligibility flags, and why a payload value does not belong in a log line.
A historical footnote: 3 KB, from 2016
One archived page does print a figure, and it is the figure a decade of size-limit posts are still built on. Adopting Handoff, in Apple's documentation archive, was last revised on 2016-04-01. It says, twice, to transfer as small a payload as possible in the userInfo dictionary, "3KB or less", because the more payload data you deliver, the longer the activity takes to resume.
The figure is easy to miss even inside the archive, which is how it ended up treated as folklore. Apple's Handoff archive holds two conceptual pages with near-identical titles: About Handoff and Adopting Handoff. One pass on this series fetched the first, found no payload guidance (there is none on it) and recorded the figure as unfindable. The note is on the second page, twice. A negative result from one archived page is a result about that page.
We also looked for an empirical breaking point and found none: no blog post, forum thread, Stack Overflow answer or conference talk reporting that activities died reliably above some N KB on any OS version. We did not measure one either, and this article will not estimate one. An estimate would read like a measurement, get quoted back without its hedges, and describe one OS version, one device pair and one radio at one moment. The honest state of the world is four facts, in this order: the current reference publishes no number, the too-large error publishes no number, nobody has published a measurement, and a 2016 page recommended 3 KB or less so that resume would stay quick.
Pick a cap, and write down why
A cap is still your decision. Resume latency is a user-visible cost, the current docs will not choose a number for you, and the radios of 2016 are not the radios in a person's pocket now. What you owe the next reader of the code is the reason next to the constant. If the number you choose is 3 KB, the comment has to say that it is the archived 2016 recommendation about latency: not a limit you measured, and not a limit Apple documents today. "We picked a number because Apple publishes nothing" leaves out the archive. "The platform enforces 3 KB" invents a ceiling. Neither sentence is available.
setTypedPayload: the first-party Codable path
You do not have to hand-roll the projection into the permitted types. The class page carries a topic group Apple titles "Managing type-safe access to user info", with two methods and an error:
- setTypedPayload(_:) is documented as "Encodes the specified payload into the user activity's user info dictionary."
- typedPayload(_:) is documented as "Decodes the user activity's user info dictionary as an instance of the specified type."
- NSUserActivity.TypedPayloadError is the failure type both of them throw.
That is a first-party Codable path over userInfo, and for most apps it is the right default. A payload struct of identifiers, and both directions:
struct DraftLocation: Codable, Equatable {
let documentID: String
let sectionID: String?
let revision: Int
}
// Sending side.
try activity.setTypedPayload(location)
// Receiving side: part 3's arrival seam.
func restore(from activity: NSUserActivity) {
do {
let location = try activity
.typedPayload(DraftLocation.self)
open(location)
} catch {
// A TypedPayloadError here means the
// dictionary is not what this version
// expects. Fall back; do not crash.
openDefaultDocument()
}
}One sentence matters more than the rest of this section. The typed API removes the hand-rolled projection. It does not publish a size, and it does not change what the dictionary is for. The encoded result lands in the same userInfo, crosses the same transport, and is still what the too-large error is about. The 2016 latency note applies to that encoded result only as history. Reading setTypedPayload as a way to put the document in the activity is the mistake this section exists to prevent.
Keep a hand-written projection for the case where the size is the point. A [String: String] you build yourself can be counted exactly, in one pass, before it ever touches an activity. That is the measurement the typed API hides. It is also pure, so projection and parsing test with no device and no second machine. Part 9 leans on that, because Handoff has no automated end-to-end path at all.
Refuse, never truncate
When a payload exceeds the cap you chose there are two responses, and only one is honest.
Truncation fails silently in the user's data. Drop the last few keys and the activity still transfers and the receiving app still opens: on the wrong section, at the wrong revision, or missing a filter the user set five minutes ago. No error anywhere. The user concludes Handoff is unreliable, a far more expensive conclusion than a feature that declines to arm itself.
Refusal fails loudly, in one log line, on the developer's side, at the moment of construction. Because the encoding is undocumented, the measurement has to happen after encoding rather than before. Encode onto a scratch activity, measure that, and only promote it if it fits:
enum ActivityPayloadRejection: Error {
/// Sizes and error kinds are safe to log.
/// Payload values are not. See part 6.
case tooLarge(bytes: Int, limit: Int)
case encodingFailed(String)
}
enum ActivityPayload {
/// Historical only. Adopting Handoff,
/// archived, last revised 2016-04-01,
/// says "3KB or less" so a resume stays
/// quick. Not a ceiling, and absent from
/// the current NSUserActivity reference.
/// Pass your own cap, and write why.
static let historicalRecommendation = 3 * 1024
/// Encodes onto a scratch activity,
/// measures the result, and only then
/// promotes it. Refuses in one piece.
static func encode<T: Codable>(
_ payload: T,
onto activity: NSUserActivity,
limit: Int = historicalRecommendation
) throws {
let scratch = NSUserActivity(
activityType: activity.activityType
)
do {
try scratch.setTypedPayload(payload)
} catch let error as
NSUserActivity.TypedPayloadError {
throw ActivityPayloadRejection
.encodingFailed(
String(describing: error)
)
}
let info = scratch.userInfo ?? [:]
let bytes = approximateBytes(of: info)
guard bytes <= limit else {
throw ActivityPayloadRejection
.tooLarge(
bytes: bytes, limit: limit
)
}
activity.userInfo = info
}
/// A stand-in, not the wire size. The
/// encoding Handoff uses is undocumented
/// and keyed archiving is certainly not
/// it; this over-counts on purpose, and
/// an unmeasurable payload is refused.
static func approximateBytes(
of userInfo: [AnyHashable: Any]
) -> Int {
let data = try? NSKeyedArchiver
.archivedData(
withRootObject: userInfo,
requiringSecureCoding: false
)
return data?.count ?? .max
}
}That measurement over-counts rather than under-counts, which is the safe direction when the real number is unknown and the comparison is against a cap you chose. The decision is a pure function of the encoded dictionary, so exactly-at-the-cap, one-byte-over and encoding-failed are unit tests that need no device. The default argument is the 2016 footnote, named so it cannot be mistaken for a platform constant. Pass a different limit when you have a reason, and put the reason in the call.
An identifier-only fallback is a legitimate second design, and in 2026 it is the ordinary one: when a richer payload is refused, send the document identifier and let the receiving app fetch the rest. That is a complete unit with its own parse rule, not a prefix of a larger one. What must never ship is a dictionary assembled for one contract and delivered missing part of it.
Build the payload lazily
The second way a payload goes wrong has nothing to do with its size. It is built too often.
The tempting shape keeps userInfo continuously current: a computed snapshot in a view, an .onChange that re-encodes, a @State initial value built at construction. Each encodes on every keystroke, in a form the user is typing into, to produce a dictionary thrown away before anything reads it. If that dictionary also carries appEntityIdentifier, you are refreshing the system's idea of the on-screen entity on every keystroke as well.
// Wrong: a full encode per keystroke, whether
// or not anything will ever read the result.
TextEditor(text: $draft.text)
.onChange(of: draft.text) { _, _ in
try? activity.setTypedPayload(
draft.location
)
activity.becomeCurrent()
}The platform already has the throttle you are reaching for. Set needsSave on the activity when your state changes. That is a flag assignment and nothing else. Before the system next saves the activity it calls the delegate's userActivityWillSave(_:), which Apple documents as the notice that the activity will be saved to be continued or persisted. That is where the payload gets built: once, at a moment the system chose, on data that is current.
final class DraftActivityOwner: NSObject,
NSUserActivityDelegate {
let activity: NSUserActivity
private let logger = Logger(
subsystem: "com.example.app",
category: "handoff"
)
private let currentLocation:
() -> DraftLocation
init(
activityType: String,
currentLocation:
@escaping () -> DraftLocation
) {
self.activity = NSUserActivity(
activityType: activityType
)
self.currentLocation = currentLocation
super.init()
activity.delegate = self
activity.isEligibleForHandoff = true
}
/// Called on every edit. Cheap on purpose:
/// a flag, not a snapshot.
func stateChanged() {
activity.needsSave = true
}
// MARK: NSUserActivityDelegate
func userActivityWillSave(
_ activity: NSUserActivity
) {
do {
try ActivityPayload.encode(
currentLocation(),
onto: activity
)
} catch {
// Refuse the whole payload. Sizes
// and error kinds in the log,
// never payload values.
activity.userInfo = nil
logger.error(
"""
Handoff payload refused: \
\(String(describing: error))
"""
)
}
}
}The cost profile flips. Editing is a boolean write. Encoding, measurement and the size decision happen once per save, off the typing path, at a point where the rejection branch has somewhere sensible to report itself. If the screen's entity identifier belongs on the activity too, set appEntityIdentifier in that same callback, so the cue and the dictionary describe one moment. The sample above stays a Codable location on purpose: an app entity is a separate type, and this part does not pretend the two are the same struct.
Continuation streams are a session, not a bigger dictionary
When the data genuinely does not fit an identifier, the current reference still has an escape hatch, and it is a different architecture. supportsContinuationStreams is "whether the continuing app can request streams to be opened back to the originating app." getContinuationStreams(completionHandler:) "requests streams back to the originating app." The delegate method userActivity(_:didReceive:outputStream:) tells the originating side that an input stream and an output stream are available to open.
The archived 2016 page describes the same hatch, in the same breath as the size note: if resuming needs more data than the initial payload can efficiently transfer, the continuing app can call back to the originating app's activity and open streams between the two. Worth knowing, and still historical colour around an API that is on the current reference.
A dictionary is fire-and-forget: the sending device can go into a bag the moment the activity is published. A stream is a live session, so the originating app must still be running, still hold the activity object, and still serve the request when the far side calls back. Every failure mode of a network conversation becomes a state your feature needs an answer for, on both ends.
So the question is whether this handoff transfers a pointer or a session. A pointer the receiving app can resolve on its own is the smaller, more robust product, and it is also the shape appEntityIdentifier already asks for. Streams are right when the data genuinely only exists on the originating device. Then you are signing up to keep that device's app alive for the length of the transfer.
What this part cannot tell you
Four gaps, stated rather than smoothed over, because each one changes what you can conclude:
- The real ceiling. No public measurement of the size at which activities actually fail was found. The current reference publishes no number. The 2016 figure is historical guidance about latency, not a measured cliff, and this article does not supply one.
- What a typed payload costs. The class page does not state the encoding setTypedPayload uses, so the byte cost of a Codable struct is not derivable from the docs. Measure the dictionary it produces.
- What the system counts. The byte count in the sample is keyed archiving over the encoded dictionary. That is not the wire encoding. It is a conservative stand-in, and the relationship between the two is unknown.
- When the too-large error fires. NSUserActivityHandoffUserInfoTooLargeError is documented by its message alone. Nothing in this pass states the threshold, whether it varies by OS version or device pair, or whether it reaches the sending side at all.
What governs the decision in 2026 is the part Apple does document. Put an identifier in the activity, and set appEntityIdentifier when the screen is one entity you are willing for Siri and Apple Intelligence to see. Keep userInfo to the state required to continue, build it lazily behind needsSave, measure what you encode, and refuse the whole payload when it does not fit your cap. The only size Apple has published is the 2016 recommendation of 3 KB or less, on an archived page, about resume latency. It is a footnote to that decision. Part 5 takes the payload that fits and asks which window it should open.
Previously (Part 3): Where Handoff Actually Arrives on iOS and macOS
Next up (Part 5): Sending Handoff to the Right Windowlands in 2 days
Comments
Loading comments…