How do you budget an APNs payload: bytes versus characters, escaped slashes, and what belongs in it?

17.8.2 · L3 · Interview

Predict first

How big are these payloads?

import Foundation
func size(_ o: Any, _ opts: JSONSerialization.WritingOptions = []) -> Int { try! JSONSerialization.data(withJSONObject: o, options: opts).count }
let small: [String: Any] = ["aps": ["alert": ["title": "Score", "body": "Pune 2 - 1 Goa"], "sound": "default", "thread-id": "match-42"], "match": 42]
print("small payload bytes:", size(small))
let text = String(repeating: "é", count: 2100)
let accent: [String: Any] = ["aps": ["alert": text]]
print("characters:", text.count, "utf8 bytes:", text.utf8.count, "payload bytes:", size(accent), "over 4096:", size(accent) > 4096)
let url: [String: Any] = ["aps": ["alert": "x"], "deeplink": "https://bank.example/a/b/c"]
print("with escaped slashes:", size(url), "without:", size(url, [.withoutEscapingSlashes]))
let emoji = "🚀"
print("emoji:", emoji.count, "character,", emoji.utf8.count, "bytes")
let silent: [String: Any] = ["aps": ["content-available": 1]]
print("silent payload:", String(data: try! JSONSerialization.data(withJSONObject: silent), encoding: .utf8)!)
let localized: [String: Any] = ["aps": ["alert": ["title-loc-key": "SCORE_TITLE", "loc-key": "SCORE_BODY", "loc-args": ["Pune", "2", "1"]], "mutable-content": 1, "interruption-level": "time-sensitive", "relevance-score": 0.5]]
let d = try! JSONSerialization.jsonObject(with: JSONSerialization.data(withJSONObject: localized)) as! [String: Any]
print("keys:", (d["aps"] as! [String: Any]).keys.sorted())
  • 2100 é characters give 4200 bytes and 4220 payload bytes, over 4096; escaped slashes add bytes
  • Characters and bytes are always equal
  • Escaping slashes saves bytes

Mental model. A postcard has a fixed size, and what counts is the ink, not the number of words: accented letters and escaped slashes use more of it.

The limit is 4096 bytes for the whole JSON body (5120 for VoIP), and it counts UTF-8 bytes of the serialised JSON, not Swift characters. In the run 2100 accented characters are 4200 bytes (each é is two), so the payload is 4220 bytes and over the limit even though the string looks short; an emoji is one character but four bytes. Serialisers add their own bytes too: JSONSerialization escapes / as \/ (66 bytes versus 61 with .withoutEscapingSlashes), which matters when you embed URLs. The system keys live under aps (alert with title, body or the loc-key family, sound, badge, thread-id, interruption-level, relevance-score, mutable-content for a service extension, content-available for a silent push); custom keys sit beside it.

Design rule: put identifiers and a short display text in the payload and fetch the rest (an authenticated request from the app or service extension). Localise on the device with loc-key and loc-args so the server sends keys, not translated prose, and measure the final bytes on the server before sending: an oversize push is rejected with PayloadTooLarge and never shown. The sizes here were measured with JSONSerialization; the server’s rejection itself was not exercised.

Size is measured in UTF-8 bytes of the serialised JSON.

Common traps

  • Counting characters. Count UTF-8 bytes of the final JSON.
  • Putting the document in the push. Send an id and fetch the body.

They might follow up with

  • Where do rich images come from? A mutable-content push and a notification service extension that downloads them.

Related questions

  • 17.1.1 How do local and remote notifications work with UserNotifications: authorization, triggers, categories, and…
  • 17.1.2 How does the Apple Push Notification service deliver a push: tokens, priority, collapse, expiry, and what can…
  • 17.8.1 How does token-based APNs authentication work: what is in the JWT, how is it signed, and how often can it be…

← How does token-based APNs authentication work: what is in the JWT…   What does a UNCalendarNotificationTrigger actually match: time zones… →