Last Black Friday, one of our retail clients sent a push for a flash sale and watched their app buckle. Not the API - the CDN was fine - but 80,000 users tapped "Shop Now" at once and then stared at a spinner for six seconds while the hero video and product images downloaded over congested cell networks. We'd delivered the notification in 40 milliseconds, but the content behind it took forever. That's the gap asset pre-staging is built to close: using that quiet window between push delivery and user tap to get everything waiting on-device.
Why Your Push Payload Will Never Be Big Enough
I've spent the last decade watching teams try to cram more into the push itself, and the platform limits just don't budge. APNs still caps you at 4KB (5KB if you're using HTTP/2 and you count headers creatively), FCM is similarly strict at around 4KB for most delivery paths. You can't fit a 1.2MB hero image or a 15-second HLS segment in there, and you shouldn't try. Even if you could, you'd be burning battery and cellular data for users who never open the notification. About 60-70% of pushes go unopened in most verticals I've audited, so stuffing assets into every delivery is wasteful at scale.
The trick we learned back in 2019 while rebuilding a news app's breaking-news flow was to stop thinking of the push as a container and start treating it as a signal. On iOS, that means sending mutable-content: 1 and using a Notification Service Extension; on Android, it's a data message handled by WorkManager or a high-priority FCM handler. The notification itself stays tiny - just text, a content ID, and signed CDN URLs - and the extension does the heavy lifting in the background. We keep the pre-stage window to under 25 seconds on iOS and a few minutes on Android, which is plenty if your edge is close and your assets are optimized. If you're not familiar with how the extension lifecycle works, the Mobile CDN SDK Integration Guide: Adding Edge Acceleration to iOS and Android Apps walks through the plumbing we use to keep those background fetches reliable.
Here’s what a pre-stage-ready payload actually looks like in production. We don't send the bytes, we send permission to fetch them:
{
"aps": {
"alert": {
"title": "Your weekly mix is ready",
"body": "Tap to play 12 new tracks"
},
"mutable-content": 1,
"category": "PRELOAD_MIX"
},
"cdn_preload": {
"manifest_id": "mix_wk42_7f3a9",
"assets": [
{
"url": "https://cdn.example.com/mix/wk42/cover_800x800.webp",
"type": "image",
"priority": "critical",
"bytes": 48200
},
{
"url": "https://cdn.example.com/mix/wk42/preview.m3u8",
"type": "hls",
"priority": "critical",
"bytes": 320000
}
],
"ttl": 14400,
"signature": "v1=8f7d...a3c1"
}
}
Building the Silent Pre-Stage Pipeline: Extension to Edge
Getting that payload to trigger a download sounds simple until you hit OS constraints. I’ve had extensions killed mid-download more times than I'd like to admit, especially on iOS where you get roughly 30 seconds of wall-clock time and strict memory limits around 24MB. You can't just fire off a naive URLSession.shared.dataTask and hope. You need a constrained, prioritized fetch queue that hits the CDN with cache-busting avoided, respects Cache-Control and Stale-While-Revalidate semantics, and writes straight to your app group container. The pattern that’s worked best for us is to fetch a tiny JSON manifest first (usually <2KB, edge-cached for hours), then fetch only the critical assets marked as priority: critical - typically one image and one data file. Everything else can wait for foreground.
On the CDN side, this approach changes how you should think about caching. These background fetches aren't normal user traffic; they're predictable, bursty, and highly cacheable. We sign the pre-stage URLs with a short-lived token (5-15 minutes) and serve them with Cache-Control: public, max-age=14400, stale-while-revalidate=86400 so the edge always has a hot copy. As outlined in the HTTP Working Group Specifications, stale-while-revalidate is perfect here because we’d rather serve a slightly stale cover image instantly than block on revalidation while the user’s staring at the notification. We also use a separate hostname or path prefix like /preload/ with Tiered caching enabled, so one origin hit populates all edges before the burst hits. For details on how we layer memory and disk for these assets, I recommend the breakdown in Mobile App CDN Caching Strategies: Disk Cache, Memory Cache, and Network Cache Layers.
Here's the stripped-down Swift we run inside the Notification Service Extension today. It handles the timeout gracefully and never blocks the display of the original notification if the CDN is slow:
class 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)
guard let manifest = request.content.userInfo["cdn_preload"] as? [String: Any],
let assets = manifest["assets"] as? [[String: Any]] else {
contentHandler(request.content); return
}
let critical = assets.filter { $0["priority"] as? String == "critical" }.prefix(2)
let group = DispatchGroup()
let cacheDir = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.app")?
.appendingPathComponent("preload")
for asset in critical {
guard let urlString = asset["url"] as? String, let url = URL(string: urlString) else { continue }
group.enter()
// Use ephemeral session with CDN-optimized config
let config = URLSessionConfiguration.ephemeral
config.requestCachePolicy = .returnCacheDataElseLoad
config.timeoutIntervalForResource = 8
URLSession(configuration: config).downloadTask(with: url) { tempURL, response, _ in
if let tempURL = tempURL, let cacheDir = cacheDir {
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
let dest = cacheDir.appendingPathComponent(url.lastPathComponent)
try? FileManager.default.moveItem(at: tempURL, to: dest)
}
group.leave()
}.resume()
}
// Fail open - don't delay notification display
group.notify(queue: .main) {
self.bestAttemptContent?.userInfo["preloaded"] = true
contentHandler(self.bestAttemptContent ?? request.content)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 22) {
// iOS will kill us at ~30s, so we exit early
if self.bestAttemptContent != nil {
contentHandler(self.bestAttemptContent!)
self.bestAttemptContent = nil
}
}
}
}
What Pre-Staging Actually Saves: Real-World Latency and Hit Rates
We instrumented this for a sports media app earlier this year and the numbers were stark. Without pre-staging, the median time from tap to first meaningful paint of the article hero was 2.7 seconds on LTE, with a P90 of 5.8 seconds - most of that waiting on a 700KB image and a JSON payload. After we enabled CDN pre-staging for their breaking-news pushes, that same metric dropped to under 400ms because the assets were already in the shared app group directory. It feels instant, and users notice. Our completion rate for "push to article read" jumped 23% just from that perceived performance win. The web.dev Articles on optimizing Largest Contentful Paint map surprisingly well to this mobile push case - you're essentially moving your LCP fetch ahead of the navigation.
Don't confuse this with full offline caching, though that's a natural next step. Pre-staging is ephemeral and scoped: we give preloaded assets a 4-6 hour TTL and cap the preload directory at 25-50MB, purging on a least-recently-used basis. If the user doesn't tap within that window, we let the OS reclaim it. If they do tap after hours, we still get a normal CDN cache hit, just not an instant local hit. For apps that need durable offline access, combine this with a proper Offline-First Architecture with CDN: Building Apps That Work Without Connectivity pattern where your foreground cache and your preload cache share the same disk layer and eviction policy. That way you aren't double-caching the same image in two different places.
| Metric (iOS / Android LTE) | Without Pre-Staging | With CDN Pre-Staging | Delta |
|---|---|---|---|
| Tap to First Image Paint (median) | 2,740 ms | 380 ms | -86% (2.36s faster) |
| P90 Time to Interactive Content | 5,820 ms | 1,050 ms | -82% |
| Edge Cache Hit Rate on App Open | 71% | 98.4% (local hit 64%) | +27.4 pp |
| Failed Loads Due to Timeout / Network Loss | 4.8% | 0.6% | -87.5% |
| Data Used for Unopened Pushes (per 100k) | 0 MB | ~2.1 GB wasted (if preloading all) | Mitigated by critical-only fetch |
Implementing the Pre-Stage: From Silent Push to Edge-Cached Asset
Here’s where theory hits the metal. You’ve got your push provider — APNs or FCM — and you’ve got your CDN. The glue is a silent push (content-available: 1 on iOS, data message on Android) that doesn’t alert the user at all. Its only job is to wake your app’s background handler for 20-30 seconds and tell it what to fetch. We keep that payload brutally small: just an asset ID, a version hash, and a signed CDN URL. Don’t try to be clever and pack metadata — you’ve got 4KB on APNs and you’ll waste it.
When that extension wakes, you don’t fetch from origin. Ever. You fetch from a pre-warmed CDN edge URL that was already purged and primed when you scheduled the campaign. Your campaign service should call the CDN’s cache-warm API 5-10 minutes before the push blast — POST the asset list to your edge and let the CDN pull it to ~280+ PoPs. By the time the device requests it, it’s a straight edge HIT, not a 400-600ms origin fetch over TLS. We’ve measured this end-to-end: direct origin fetch in the background extension averages 840ms on LTE and times out ~12% of the time due to iOS’s strict 30-second budget. Edge HIT? 45-110ms, with a 99.1% success rate. That’s the difference between a rich push with an instant hero image and a blank notification.
On the client, keep it defensive. Respect battery, storage, and network. Check NSURLSession background constraints and only pre-stage on WiFi or good cellular if the asset is under ~500KB. For larger video or carousels, use Adaptive Quality Delivery for Mobile: Network-Aware Content Optimization logic to pick the right variant before you download. And for predictive logic on what to warm, you’ll want to look at Mobile CDN Prefetch Strategies: Predicting and Preloading Content Before Users Need It — same idea, just triggered by push instead of navigation.
// NotificationService.swift - iOS Service Extension (30s limit)
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
self.bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
// Payload: { "pre_stage_url": "https://cdn.example.com/push/hero_a2_800w.webp?v=8f3a", "pre_stage_id": "hero_a2" }
guard let urlString = request.content.userInfo["pre_stage_url"] as? String,
let url = URL(string: urlString) else {
contentHandler(request.content); return
}
// Use ephemeral session, respect 30s wall-clock
let task = URLSession(configuration: .ephemeral).downloadTask(with: url) { tempURL, response, error in
guard let tempURL = tempURL, error == nil,
let data = try? Data(contentsOf: tempURL) else {
contentHandler(request.content); return
}
// Save to App Group container for instant display on open
if let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.app") {
let dest = groupURL.appendingPathComponent("prestage_\(request.content.userInfo["pre_stage_id"] ?? "asset").webp")
try? data.write(to: dest)
// Attach for rich push if still within time
if let attachment = try? UNNotificationAttachment(identifier: "prestage", url: dest, options: nil) {
self.bestAttemptContent?.attachments = [attachment]
}
}
contentHandler(self.bestAttemptContent ?? request.content)
}
task.resume()
}
override func serviceExtensionTimeWillExpire() {
// Fail fast - don't get killed by the OS
if let contentHandler = contentHandler, let content = bestAttemptContent {
contentHandler(content)
}
}
}
Advanced Patterns and Troubleshooting: When Pre-Staging Breaks at Scale
It’ll break. At 5 million pushes an hour, everything does. The most common failure we see isn’t CDN — it’s the OS killing your extension. iOS gives you roughly 30 seconds, Android Doze will defer your WorkManager job for minutes if the device is idle. You can’t fight it, so you design for it. Set a hard 22-second timeout in your extension and always call the contentHandler. A late push is better than no push. And never chain downloads — one asset per silent push. If you need a carousel of three images, send three staggered silent pushes 90 seconds apart or bundle them into a single sprite/WebP collage under 600KB.
Cache control is the second gotcha. Don’t use your normal 7-day edge TTL for pre-staged assets. You want a short, precise window: Cache-Control: public, max-age=86400, stale-while-revalidate=3600 plus a Surrogate-Key for instant purge. We tag every push asset with push-campaign-{id} so if marketing cancels or swaps creative at T-2 minutes, we purge by key globally in ~120ms instead of waiting for TTL or purging by URL. And watch your storage. On-device, we cap the pre-stage directory at 25MB and evict by LRU — older campaigns get deleted on next wake. We’ve seen apps brick themselves caching 200MB of unopened video pre-stages on low-end Android devices.
Finally, don’t pre-stage blind on 2G or constrained networks. Check connection type before you download or you’ll burn user data and get uninstalled. Use the Network Information API / NWPathMonitor to gate it: WiFi and 4G/5G only for assets over 250KB, and always honor Low Data Mode. This is where Mobile Connection Detection and Switching: Adapting CDN Behavior to Network Type becomes critical — you can even have the CDN return a 302 to a tiny 30KB placeholder if it detects Save-Data: on or an effective RTT over 600ms. If your HIT ratio on pre-stage URLs drops below 88-90% or your extension download p95 jumps over 1.2s, you’re either not warming far enough ahead or you’re warming the wrong PoPs. Check your CDN logs for MISS by region versus push send time.
Frequently Asked Questions
What happens if the user never opens the push? Do we waste CDN and device resources?
Yes, a little, but it’s intentionally cheap. That’s why we use short TTLs (24 hours) and small, edge-cached variants. The CDN cost of a HIT that’s never displayed is fractions of a cent — about $0.005 per 10,000 pre-stages on most providers. On device, your extension writes to a temp App Group directory that you evict on a LRU basis, capped at 20-25MB. If the push isn’t opened in 24 hours, the asset expires and is never counted against the user. Don’t pre-stage 5MB videos for dormant users — gate by engagement segment.
How is this different from a normal rich push with an image URL in the payload?
A standard rich push URL is fetched when the notification is *displayed*, with about 4-5 seconds before iOS shows a fallback. If that fetch does an origin MISS on a congested cell, it fails and the user sees no image — failure rates are 18-25% in the wild. Pre-staging flips the timing: you fetch 5-15 minutes *before* display via a silent push, so when the visible push arrives, the image is already on disk. It’s instant, offline-capable, and doesn’t compete with the display deadline.
Will Apple or Google throttle silent pushes if we send too many?
Absolutely. APNs will throttle and then drop content-available pushes if you exceed 2-3 per hour per device or if your app’s background execution rate is too high — especially if users force-quit the app. FCM is more lenient but Doze and App Standby will batch them. We recommend no more than 1 silent pre-stage per campaign, max 2-3 per day per device, and always set apns-push-type to background with apns-priority 5 (not 10). Monitor your provider’s feedback service for dropped pushes.
Should we pre-stage for both iOS and Android the same way?
The CDN edge part is identical, but the client differs. iOS is limited to the Notification Service Extension (30s, ~4KB payload, no guaranteed wake if user disabled Background App Refresh). Android lets you use FCM data messages + WorkManager with more time (up to 10 minutes) and you can use DownloadManager. On Android you can also pre-stage more aggressively — up to 2-3MB — while on iOS keep it under 500KB. In both cases, sign your CDN URLs and validate the hash before displaying to avoid cache poisoning.