◉RORK LABJP
●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…
Articles/Dev Tools
⬡ Dev Tools/2026-05-01Advanced

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.

Rork585Notification Service ExtensionRich PushAPNs3Swift48iOS116Push Notifications9Encryption

✦ Premium Article

"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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-09-21
A build that stops with exit code 0 — the flag that silenced the log, and the check that let an empty file through
Two reports landed in the same week: eas build --local stopping with exit code 0 but produced no further output, and a zero-byte stub that never got embedded. Both came down to settings that reduce output. Here is how to audit your own scripts.
⬡ Dev Tools2026-09-05
Your Widget Extension Can Submit a BGTaskScheduler Request. It Just Cannot Register the Handler
Calling BGTaskScheduler from a widget extension compiles, submits, and returns true — and then nothing runs. Here is why registration belongs to the host app only, and how I moved refresh ownership back where it belongs across my wallpaper apps.
⬡ Dev Tools2026-07-27
When to Raise Your Minimum iOS Version — Count Leftover Branches, Not User Percentages
Judging a minimum OS bump by usage share produces the same answer every year, so the decision never happens. Here is the annotation convention, the sweep script that counts how many iOS and Android branches each candidate floor would retire, and what to watch for 30 days after.