●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Adding a Notification Service Extension to a Rork App — Rich Push, Encrypted Payloads, and Dynamic Rewriting
Wiring a Notification Service Extension into a Rork-built iOS app — image attachments, end-to-end encrypted payloads, and dynamic APNs payload rewriting — with working Swift code and the production pitfalls that bit me along the way.
"The same notification, with and without an image, drives more than 2x the open rate" — when you watch the top App Store apps closely, the teams that have figured out Rich Push are the ones whose retention and revenue charts look very different from the teams that haven't. So it's frustrating when you ship an app with Rork, try to add image notifications, and run straight into "the image doesn't show up even with mutable-content: 1," "my Notification Service Extension keeps timing out at 30 seconds," or "this works in the simulator but not in production."
I've walked that road. The first time I built a Notification Service Extension (NSE), I had to stitch together fragments from ten different Apple docs, Stack Overflow threads, and forum posts before anything actually rendered. The awkward part was that none of those sources were wrong. They just never agreed on what to do first.
So what follows is ordered the way I eventually settled on doing it: minimum image attachment → decrypting end-to-end encrypted payloads → rewriting the payload on-device → the monitoring that starts mattering the week after you ship. The code assumes you've generated native iOS targets with Rork Max, but the same sequence holds if you're coming from an Expo bare workflow or a React Native project.
Why a Notification Service Extension Is Worth the Effort
iOS push notifications, by default, just show whatever APNs hands them. That works for plain text, but production apps run into three hard limits.
First, APNs payloads can't carry attachments. The hard cap is 4096 bytes over HTTP/2 — not even one Retina image fits. Instead, your payload includes an image URL, and the device has to download and attach it before showing the notification. The "modify the notification on-device" job is exactly what a Notification Service Extension is for.
Second, anything you put in an APNs payload passes through Apple's servers. For chat apps, finance apps, or any product where end-to-end encryption is non-negotiable, you can't ship the message body in plaintext. The server sends an encrypted payload, the device decrypts it inside the extension, and only then does the user see the actual content. This too is an NSE job.
Third, per-user, per-locale, per-timezone rewriting. If you want to send "3 hours until your deadline" to users across many languages, generating the localized strings on the server scales poorly. Rendering them on-device, where the locale and the user's nickname already live, is far cleaner — and again, NSE is where that code runs.
The thread tying all three together: APNs is not a trusted boundary. The device is. NSE is the only sanctioned way to do final-mile work on a notification, and Rork apps can use it freely once you open the native target and add the extension.
The Notification Pipeline, End to End
Before any code, it's worth being clear about when an NSE actually runs and what it can and can't do. Without this mental model, the 30-second deadline and the memory ceiling will feel like random gotchas.
For a normal push, APNs hands the payload to the system, which renders it in Notification Center or the lock screen. When the payload contains mutable-content: 1, the system instead launches your app's NSE first and hands the notification to your UNNotificationServiceExtension subclass.
Inside the extension, didReceive(_:withContentHandler:) is called. You can rewrite the title, attach an image you just downloaded, decrypt a sealed payload, swap in a localized template — and when you're done, you call contentHandler with the modified content. The system then displays whatever you returned.
The hard constraints: 30 seconds of wall-clock time, and roughly 24 MB of memory. If you're getting close to 30 seconds, the system calls serviceExtensionTimeWillExpire() so you can return your best-effort result before being killed. If you ignore it and overrun, the user sees the original payload — usually meaning no image and no decrypted body.
The memory number is one I had wrong for a long time. I'd mentally filed extensions together and assumed something near 50 MB, built image handling on that assumption, and only found out when a device crash log came back with EXC_RESOURCE / RESOURCE_TYPE_MEMORY (limit=24 MB). A notification extension's memory budget can't be estimated from how the main app behaves. 24 MB is small enough that decoding a single 4K image with UIImage(data:) will exceed it on its own, so downsampling isn't an optimization here — it's the only way the path works at all.
One more thing that catches people: the NSE runs in a different process from your main app, with its own bundle identifier. Sharing tokens or settings via Keychain or UserDefaults requires explicit App Group and Keychain Access Group configuration. If you skip this, you'll spend an evening debugging errSecItemNotFound.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If your push notifications never seem to show images and your open rates aren't moving, you'll walk away with a working Rich Push implementation today
✦You'll learn how to encrypt APNs payloads on the server and decrypt them on-device inside the extension, including the Swift code and the Keychain access group setup that usually trips people up
✦You'll get a battle-tested pattern for staying inside three separate ceilings — the 30-second runtime, the ~24 MB memory budget, and the attachment size limits — while still doing image downloads, localization, and personalization
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The setup assumes you've already generated a native iOS target with Rork Max. Open the Xcode project, click the + in the targets list, and add a Notification Service Extension. The naming convention is to suffix your main bundle ID — if your app is com.example.myapp, the extension is com.example.myapp.NotificationService.
Xcode generates a starter NotificationService.swift that looks like this:
import UserNotificationsclass NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? override func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) if let bestAttemptContent = bestAttemptContent { // Modify the notification here bestAttemptContent.title = "\(bestAttemptContent.title) [modified]" contentHandler(bestAttemptContent) } } override func serviceExtensionTimeWillExpire() { // Last chance before the system kills us if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { contentHandler(bestAttemptContent) } }}
Run it as-is and your notifications will get [modified] appended to the title. Use this scaffolding as the launch pad for image attachments and decryption.
Don't forget the App Group. Under Project → Signing & Capabilities, add the same App Group (e.g. group.com.example.myapp) to both the main target and the NSE target, and verify the entitlements files Xcode generates reflect it. Without this, your main app and your NSE can't share UserDefaults, files, or — most critically — Keychain entries.
Minimum Viable Image Attachment
With the extension wired up, the first feature to add is image support. Have your server send a payload like this:
{ "aps": { "alert": { "title": "New article published", "body": "Rork × Notification Service Extension Production Guide" }, "mutable-content": 1, "sound": "default" }, "image_url": "https://cdn.example.com/notifications/article-cover-12345.jpg"}
Without mutable-content: 1, the NSE never runs. I've seen production teams ship a campaign with that flag missing in one template variant — it's worth validating in your delivery pipeline before every blast.
Inside the extension, pull image_url out of the payload, download it to a temp directory, and attach it to the notification:
override func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) guard let bestAttemptContent = bestAttemptContent, let imageURLString = request.content.userInfo["image_url"] as? String, let imageURL = URL(string: imageURLString) else { contentHandler(self.bestAttemptContent ?? request.content) return } let task = URLSession.shared.downloadTask(with: imageURL) { [weak self] localURL, response, error in guard let self = self, let localURL = localURL, error == nil else { // If we can't fetch the image, still deliver the text — silent failure is worse contentHandler(bestAttemptContent) return } // Save with a unique filename — the extension matters for MIME detection let tmpDir = FileManager.default.temporaryDirectory let ext = imageURL.pathExtension.isEmpty ? "jpg" : imageURL.pathExtension let savedURL = tmpDir.appendingPathComponent(UUID().uuidString + "." + ext) do { try FileManager.default.moveItem(at: localURL, to: savedURL) let attachment = try UNNotificationAttachment( identifier: "image", url: savedURL, options: nil ) bestAttemptContent.attachments = [attachment] contentHandler(bestAttemptContent) } catch { // Even if attaching fails, deliver the text body contentHandler(bestAttemptContent) } } task.resume()}
The non-obvious rule: always call contentHandler, even on failure. If you forget on any branch, the system can't render the notification at all and the user just sees nothing. Wire contentHandler into every error path.
URLs without a clean extension (CDN URLs with query strings, signed URLs) are particularly nasty. UNNotificationAttachment infers the type from the saved file's extension, so if you save the file as imagefile with no .jpg, the system silently drops the attachment. I lost two hours to this once before realizing the cache headers had nothing to do with it.
Guessing at filenames is the fragile version. Declaring the type outright is the durable one:
import UniformTypeIdentifiers// Derive the UTI from the response's Content-Type instead of trusting the pathlet mime = (response as? HTTPURLResponse)?.mimeType ?? "image/jpeg"let utType = UTType(mimeType: mime) ?? .jpeglet attachment = try UNNotificationAttachment( identifier: "image", url: savedURL, options: [UNNotificationAttachmentOptionsTypeHintKey: utType.identifier])
Pass the UTI through UNNotificationAttachmentOptionsTypeHintKey and the extension on disk stops mattering. If your CDN returns things like ?w=1024&fm=webp, I'd make this the default rather than the special case.
The Third Reason Attachments Disappear — Size and Format Ceilings
mutable-content: 1 is set. The download returned 200. The image still doesn't show. When those three are all true, the remaining suspect is the attachment itself.
UNNotificationAttachment enforces per-type size limits, and anything over the line is rejected at attach time.
Type
Limit
What you actually see
Audio
5 MB
Notification delivers. Attachment simply absent
Image
10 MB
Same — and the download log still reads as a success
Video
50 MB
Same
What makes this hard to catch is that the failure arrives as a valid result rather than an exception. In the code above, catch runs contentHandler(bestAttemptContent) and the user gets a perfectly reasonable text notification. Without telemetry, that's indistinguishable from success. Meanwhile the gorgeous 12 MB PNG your designer exported downloads in under a second on office Wi-Fi, so it never reproduces locally.
What worked for me was measuring the file before building the attachment, and skipping — with a log line — anything close to the ceiling. After downsampling you'll usually land around 200 KB, so the check almost never fires. That's the point: when it does fire, it's telling you a CDN variant rule broke.
The body is the promise; the attachment is the bonus. Separating those two has made triage on notification bug reports much faster for me.
Decrypting End-to-End Encrypted Payloads
For apps where confidentiality matters, you don't put plaintext into APNs. Instead, the server sends an encrypted blob, and the device decrypts it inside the NSE. The shape of the design:
The server uses a per-device shared secret — either provisioned out of band or derived via ECDH — to encrypt the body with AES-GCM. The APNs payload carries the ciphertext, the nonce (IV), and the auth tag, plus a generic placeholder body like "You have a new message" for users who haven't unlocked yet.
In the NSE, the main app's stored Keychain key is read via the shared Keychain Access Group, and the payload is decrypted before the title/body are rewritten. Sharing the key works because the Keychain Access Group ties both targets to the same protected storage.
import CryptoKitimport Securityprivate func loadSymmetricKey() -> SymmetricKey? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "com.example.myapp.notification.key", kSecAttrAccessGroup as String: "group.com.example.myapp", kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) guard status == errSecSuccess, let data = item as? Data else { return nil } return SymmetricKey(data: data)}private func decryptPayload( cipherBase64: String, nonceBase64: String, tagBase64: String, key: SymmetricKey) -> String? { guard let cipher = Data(base64Encoded: cipherBase64), let nonceData = Data(base64Encoded: nonceBase64), let tag = Data(base64Encoded: tagBase64), let nonce = try? AES.GCM.Nonce(data: nonceData) else { return nil } let sealedBox = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: cipher, tag: tag) guard let box = sealedBox, let plaintext = try? AES.GCM.open(box, using: key) else { return nil } return String(data: plaintext, encoding: .utf8)}
Call these from didReceive and overwrite the title/body with the decrypted text. If the key isn't available or decryption fails — design for this — fall back to the generic placeholder body. The temptation is to drop the notification entirely on failure; resist it. Users read "no notification" as "the message didn't arrive," and your retention takes the hit instead of your security review.
The setup that bites people is Keychain Access Group configuration. Both targets need the same Access Group in entitlements and Keychain Sharing enabled in Capabilities. Skip a step and you'll get errSecItemNotFound in production with no log to explain why.
"Is it really safe to keep the key on the device?" is the right question to ask. The answer leans on Apple's Secure Enclave / Keychain protection model: keys stored with the right accessibility class can only be retrieved by physically possessing the device and getting past Face ID or the passcode. That's a strictly smaller attack surface than letting a server read your messages. For maximum safety, set kSecAttrAccessibleWhenUnlockedThisDeviceOnly so the key is only readable while the device is unlocked and never escapes that hardware.
Dynamic Rewriting — Localization and Personalization
The other underrated NSE feature is rewriting payloads using on-device state. Imagine sending only a key (new_message_from) and a variable bag ({name: "Alex"}) over APNs, and rendering the localized string on-device based on the user's current locale.
Three concrete wins from this pattern: server templates stay simple (no per-language copy management), copy A/B tests can ship in app updates without touching APNs, and personalization (nickname, theme, etc.) stays on the device where it belongs.
private func renderLocalized( key: String, args: [String: String], locale: Locale) -> String { // Read a JSON file the main app wrote into the App Group container let containerURL = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.myapp") let langCode = locale.identifier.prefix(2) // "ja", "en", etc. let url = containerURL? .appendingPathComponent("notifications-\(langCode).json") guard let url = url, let data = try? Data(contentsOf: url), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String], let template = dict[key] else { return key // Fall back to the raw key } var result = template for (placeholder, value) in args { result = result.replacingOccurrences(of: "{\(placeholder)}", with: value) } return result}
The main app is responsible for writing the latest message JSON to the App Group container on first launch and after updates. With that in place, the NSE never has to touch the network for localization — which matters because every network round-trip eats into the 30-second budget and the battery.
Living Inside 30 Seconds and 24 MB
The biggest production trap with NSE is resource limits. Things work fine in the simulator, then hit production where images are larger, networks are slower, and parallel downloads are the norm — and suddenly extensions get killed mid-flight or crash on memory.
For time, always implement serviceExtensionTimeWillExpire(). The principle: if we're nearly out of time, ship the text without the image rather than nothing.
override func serviceExtensionTimeWillExpire() { // Image fetch didn't finish — deliver the text-only version guard let bestAttemptContent else { return } finish(bestAttemptContent, outcome: "expired") // single exit, defined below}
You'll often see the expiry path call contentHandler directly. When it does, that path quietly skips both your timing measurements and your double-call guard. Keep one exit.
For memory, downsample aggressively. Loading a full image with UIImage(data:) is the easy way to blow past 24 MB. Use CGImageSourceCreateThumbnailAtIndex to size the image down to what the notification UI actually needs:
import ImageIOprivate func downsample(imageAt url: URL, to maxPixelSize: CGFloat) -> URL? { let options: [CFString: Any] = [ kCGImageSourceShouldCache: false ] guard let src = CGImageSourceCreateWithURL(url as CFURL, options as CFDictionary) else { return nil } let thumbnailOptions: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceThumbnailMaxPixelSize: maxPixelSize ] guard let cgImage = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbnailOptions as CFDictionary) else { return nil } let outputURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString + ".jpg") guard let dst = CGImageDestinationCreateWithURL(outputURL as CFURL, "public.jpeg" as CFString, 1, nil) else { return nil } CGImageDestinationAddImage(dst, cgImage, nil) guard CGImageDestinationFinalize(dst) else { return nil } return outputURL}
Worth wiring the pieces together explicitly, because a helper that exists but never gets called is easy to miss in review — I shipped exactly that for a while, with the download handler attaching the original file. Downsampling belongs immediately before the attachment is built.
let task = URLSession.shared.downloadTask(with: imageURL) { [weak self] localURL, response, error in guard let self, let localURL else { self?.finish(bestAttemptContent, outcome: "download_failed") return } let mime = (response as? HTTPURLResponse)?.mimeType ?? "image/jpeg" // Never attach the original: shrink it, measure it, then attach it guard let smallURL = self.downsample(imageAt: localURL, to: 1024) else { self.finish(bestAttemptContent, outcome: "downsample_failed") return } if let attachment = try? self.makeAttachment(from: smallURL, mime: mime) { bestAttemptContent.attachments = [attachment] self.finish(bestAttemptContent, outcome: "ok") } else { self.finish(bestAttemptContent, outcome: "attachment_rejected") }}task.resume()
With downsample → makeAttachment → finish as a single line, adding webp support or multiple attachments later only changes the middle.
Notification UI rarely needs more than 800–1024 px. If your CDN keeps handing you 4K originals, a server-side resized variant is the better fix. Doing it in the NSE is a fallback for when you can't change the server.
Monitoring, Debugging, and Production Operations
Because the NSE runs in a separate process, debugging has its own quirks. In Xcode, select the NSE scheme and hit Run; it'll attach to the simulator or device and wait for a notification. In Console.app, filter by subsystem:com.apple.Pushkit (or your own subsystem if you've set one up) to follow what's happening on real devices in the field.
In production, separate two metrics: delivery rate and image render rate. The APNs response only confirms delivery to the device — it doesn't tell you whether the image rendered. Use os_log or the Logger API inside the extension to record image-fetch and decryption outcomes, then forward them to Sentry or Crashlytics. If you want a more sophisticated baseline before adding visuals, the advanced segmentation guide for Rork push notifications is a useful starting point — once your delivery rules are right, image impact becomes measurable. Crashlytics supports subprocess initialization, so the NSE target can carry its own client.
Three pitfalls I see most often when teams ship their first NSE.
Forgetting mutable-content: 1. A single template variant missing the flag will silently drop image rendering for that campaign. Validate the flag in your server-side delivery logs and gate the send pipeline on it.
Shipping HTTP image URLs. App Transport Security blocks plain HTTP downloads from extensions. Use HTTPS on the CDN and avoid Info.plist exceptions — they'll bite you again at App Review.
Calling contentHandler twice. It's easy to write a fallback and forget the return on the success path, leading to duplicate calls and a runtime warning. Use defer or careful return placement to guarantee a single call per invocation.
Testing Strategy — Beyond "It Works on My Simulator"
The simulator is a poor proxy for what actually happens with notifications in the wild. A test strategy that catches the production-only failures looks like this.
Layer 1 — Local payload simulator. During development, you don't want to wait for real APNs traffic to test extension changes. Drop a JSON file into your project (e.g. test-payload.apns) and drag it onto the running simulator while the NSE scheme is active. The system invokes your extension just as it would for a real push.
Leave out Simulator Target Bundle and the simulator silently ignores the file — no error, no log line. Everyone hits that once. This is the fastest feedback loop and where 80% of NSE bugs should be caught.
Layer 2 — Real APNs against TestFlight builds. Some bugs only show up against the real APNs gateway — particularly anything related to delivery thresholds, throttling, or production certificate vs sandbox certificate behavior. Have a staging endpoint that pushes to TestFlight builds and run the same payload variants you'd send in production. The "production APNs vs development APNs" gotcha is real: a build signed with the App Store distribution profile won't receive notifications sent to the development APNs gateway, and vice versa.
Layer 3 — Field telemetry. Once shipped, instrument the NSE to report what actually happened. The minimum I'd track:
Funnel every outcome — success, failure, and expiry — through a single exit point. Collapsing them there also kills the double-contentHandler bug for good.
private let startedAt = CFAbsoluteTimeGetCurrent()private let log = Logger(subsystem: "com.example.myapp.nse", category: "delivery")/// Every path out of the extension goes through hereprivate func finish(_ content: UNNotificationContent, outcome: String) { guard let handler = contentHandler else { return } // second call stops here let elapsed = CFAbsoluteTimeGetCurrent() - startedAt log.log("outcome=\(outcome, privacy: .public) elapsed=\(elapsed, privacy: .public)") contentHandler = nil handler(content)}
Each layer catches a different class of failure, and none of them substitutes for the others:
Environment and certificate differences, real network latency
TestFlight + production APNs
Gateway mismatches, distribution profiles, throttling
The spread across real networks and devices
Field telemetry
The latency tail, breakdown of failure reasons
Root cause — it observes, it doesn't explain
Send these as breadcrumbs to your APM tool. The first time you see a real-world distribution of "time to contentHandler" — usually a long tail with some 25-second outliers — you'll understand why the timeout exists and where your bottlenecks are.
Cross-Platform Parity — What Android Users See
If your app ships on both iOS and Android, the equivalent of NSE on the Android side is a combination of FCM message handling in a FirebaseMessagingService, NotificationCompat for rich notifications, and BigPictureStyle for image attachments. The constraints are different — Android's foreground service has more headroom — but the shape of the work is similar: the device modifies the payload before display.
The catch for cross-platform teams: payload structure should be the same on both platforms. Don't ship one schema for iOS and another for Android. Use a single canonical envelope (e.g. {type, body, image_url, encrypted_payload}) and have each platform's handler consume it. This makes server-side delivery code testable and keeps your campaign tooling sane. I've seen teams maintain two divergent schemas and watch their A/B test infrastructure quietly produce inconsistent results for a year.
The notification sound is another place where iOS and Android diverge silently. iOS picks up aps.sound; Android wants a notification channel registered up front. If parity matters, document the registration on both sides as part of your push setup runbook.
When NSE Isn't Enough — Notification Content Extensions
NSE handles modifying the notification's data, but the visual UI you see in Notification Center is still the system's standard layout. If you want a custom view — interactive forms, video previews, branded chrome — you'll add a Notification Content Extension on top of NSE.
Content extensions render a SwiftUI or UIKit view when the user expands the notification (3D Touch, long press, or pull). They're useful for chat previews with multiple message bubbles, RSVP-style interactions, live event scoreboards, and similar use cases where the standard layout falls short. The trade-off is more code and more state to manage; a content extension that looks beautiful in the design comp but is buggy in production hurts the user experience more than no custom UI at all.
A reasonable rule of thumb: ship NSE first, get image and decryption working, watch a month of metrics, and only consider a content extension if you have a clear product hypothesis — usually "a richer preview will reduce taps required to get the answer the notification implies."
A Worked Example — Chat App Notification Pipeline
To make all the pieces concrete, here's how the components combine for a privacy-respecting chat app.
The server has a per-conversation symmetric key, shared with the receiving device at conversation creation time. When a message is sent, the server:
Encrypts the message body with AES-GCM using the conversation key
Sends an APNs payload with a placeholder body ("New message"), the encrypted ciphertext, the nonce, the auth tag, and mutable-content: 1
Optionally includes a sender avatar URL for the image attachment
The device, on receiving the push:
The system invokes the NSE because of mutable-content: 1
The NSE reads the conversation key from the shared Keychain Access Group
The NSE decrypts the ciphertext and rewrites the title (sender name) and body (message text)
The NSE downloads the avatar image, downsamples it, and attaches it as a UNNotificationAttachment
The NSE calls contentHandler with the modified content
The system shows the notification with the actual sender name, decrypted body, and avatar
What the user sees: a notification that looks just like any iMessage or WhatsApp push — sender name, message body, sender avatar — except the server never had access to any of that information in plaintext. That's the design payoff for building this pipeline correctly.
The pieces I'd ship in order if I were building this from scratch today: image attachment first (lowest risk, biggest visual win), then localization rewriting (cleaner server templates), then encrypted payloads (most complex, only worth it if confidentiality is a product requirement).
Production Readiness Checklist
The list I run through before shipping any NSE change:
All image URLs are HTTPS (HTTP is silently blocked by ATS in extensions)
Image downsampling is in place; memory peak stays under 20 MB in Xcode's Memory Gauge, against a ~24 MB ceiling
Byte size is checked before attaching (10 MB images, 5 MB audio, 50 MB video)
serviceExtensionTimeWillExpire() always returns a text-only fallback
App Group is enabled on both the main target and the NSE target
Keychain Access Groups match across both targets
mutable-content: 1 is hard-coded in server templates and checked in delivery logs
Image rendering is verified on the lock screen, not just in Notification Center previews
Airplane-mode tests confirm text-only delivery works
Encrypted notifications fall back to generic copy when the key is missing
The NSE was tested via TestFlight against the production APNs environment (development and production APNs differ — easy to forget)
If you can clear this list before release, you should see "I'm not getting notifications" support tickets drop by roughly 90%.
The One Thing to Do Today
If you've read this far, the one thing worth doing today is checking your server-side push pipeline to confirm mutable-content: 1 is actually present in the payloads. If it isn't, add it. That single change unblocks half of the "the image isn't showing up" complaints you'll hear once you eventually ship an NSE.
Once you have the NSE in place, A/B test "with image" versus "text only" and watch the open rate. Most apps see image notifications open at 1.5–2x the rate of plain text. As a retention lever, that beats a lot of new-feature work on cost-per-result.
Building native extensions on top of a Rork app is unfamiliar territory at first, but it's also where Rork stops feeling like a no-code tool and starts feeling like a real iOS development environment — one where you can call into any Apple framework you need. If you want to go past notifications and into full native call UI, Rork with CallKit and PushKit for VoIP apps picks up the same thread — receiving a dedicated push on a separate route is the same "finish the work on the device" idea, one layer deeper.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.