Background Download Management with CDN: Efficient Asset Fetching When App Is Idle

I was sitting in an airport lounge last year when a product manager showed me his news app — every article image was blank after he landed. The app tried to fetch everything in the foreground right after launch, got killed by poor Wi-Fi, and had no assets cached. We'd spent months tuning the CDN edge, but we hadn't planned for the one moment users actually needed the content: when the app was idle hours earlier. That's the whole problem background download management solves.

Why Idle Fetching Breaks If You Treat It Like Foreground Fetching

When I started working on mobile delivery back in 2014, most teams treated background transfers as just foreground requests with a different trigger. You’d fire the same GET to the same origin path and hope iOS or Android let it finish. It doesn't work that way. The OS gives you a tiny execution window — on iOS you might get 30 seconds of BGAppRefreshTask time, on Android it's constrained by Doze and WorkManager constraints — and if your CDN hasn't prepared the asset to be fetched quickly, you'll time out before the first byte is useful.

I learned this the hard way with a streaming client that tried to prefetch video thumbnails at night. We were pulling 2-3MB image sprites directly from origin through the CDN without any edge optimization. Each request required a TLS handshake, a cache lookup, and in 40% of cases a revalidation to origin because our Cache-Control headers were too conservative. The background task expired, the OS flagged us for excessive background activity, and our fetch frequency got throttled for a week. You can't brute-force your way through idle windows; you have to make each byte as cheap as possible to retrieve.

That’s where your CDN configuration has to change. For idle fetching you want assets that are already hot at the edge, served with explicit freshness lifetimes, and split for resumable delivery. If you're just starting with edge acceleration, the Mobile CDN SDK Integration Guide: Adding Edge Acceleration to iOS and Android Apps covers how to wire the SDK so background sessions actually hit the edge and not origin. For background work specifically, I now set longer s-maxage values for prefetchable assets and use stale-while-revalidate so the edge can serve immediately while it refreshes in the background, as described in the HTTP Working Group Specifications.

// iOS: Register and schedule a background fetch that hits CDN-hot assets
import BackgroundTasks

BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.app.prefetch", 
  using: nil) { task in
  self.handlePrefetch(task: task as! BGAppRefreshTask)
}

func schedulePrefetch() {
  let request = BGAppRefreshTaskRequest(identifier: "com.app.prefetch")
  request.earliestBeginDate = Date(timeIntervalSinceNow: 2 * 3600) // idle window
  request.requiresExternalPower = false
  request.requiresNetworkConnectivity = true
  try? BGTaskScheduler.shared.submit(request)
}

func handlePrefetch(task: BGAppRefreshTask) {
  let session = URLSession(configuration: .background(withIdentifier: "cdn.prefetch"))
  // These URLs are versioned and edge-cached for 7 days
  let assets = [
    "https://cdn.example.com/assets/v2/feed-sprite-720.webp",
    "https://cdn.example.com/assets/v2/config-delta.json"
  ]
  for url in assets {
    var req = URLRequest(url: URL(string: url)!)
    req.cachePolicy = .returnCacheDataElseLoad
    req.setValue("mobile-prefetch/1.0", forHTTPHeaderField: "Purpose")
    session.downloadTask(with: req).resume()
  }
  task.setTaskCompleted(success: true)
}

Getting the OS and the CDN to Cooperate on Timing

Mobile operating systems don't care how fast your CDN is — they'll kill your task if you break their rules. Android's WorkManager with NetworkType.CONNECTED and RequiresBatteryNotLow is far more reliable than a foreground service you try to keep alive. On iOS, BGProcessingTask is your friend for larger downloads because Apple gives you minutes, not seconds, but you must tell the system you need network. I’ve seen teams request 10-minute background windows and then waste 8 minutes waiting for DNS and TLS to a cold edge. The fix is to keep connections warm and assets small.

On the CDN side, I always configure a separate prefetch route. Don't reuse your normal app API cache rules. For example, /assets/prefetch/* gets a 7-day edge TTL, immutable where possible, and origin shielding so only one edge PoP fetches from origin if there's a miss. We also enable HTTP/2 or HTTP/3 and Range request support at the edge so a download interrupted in the last idle window can resume with a 206 instead of restarting. The web.dev Articles on background fetch and resumable downloads explain this pattern well, and it's straight from the spec for partial content.

You also need to coordinate freshness between your on-device cache and the edge. I like a three-layer setup: an in-memory LRU for what's on screen, a disk cache for what was fetched in the last 24 hours, and the CDN edge as the third layer. When the background task runs, you first check the disk cache manifest, then request only the deltas. If you haven't designed that layering yet, read the Mobile App CDN Caching Strategies: Disk Cache, Memory Cache, and Network Cache Layers — it saves you from re-downloading 50MB of unchanged JSON every night. I made that mistake in 2019 and our users' data bills told us immediately.

# CDN edge config (VCL / Edge Worker pseudo-code) for idle-fetch route
# Serve prefetch assets fast, support resume, allow stale while revalidating

sub vcl_recv {
  if (req.url ~ "^/assets/prefetch/") {
    set req.http.Purpose = "prefetch";
    # allow Range requests to resume interrupted background downloads
    if (req.http.Range) { return (pass); }
  }
}

sub vcl_backend_response {
  if (bereq.url ~ "^/assets/prefetch/") {
    # Keep at edge for 7 days, serve stale for 1 day while revalidating
    set beresp.ttl = 7d;
    set beresp.grace = 1d;
    set beresp.http.Cache-Control = "public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400, immutable";
    set beresp.http.Accept-Ranges = "bytes";
    # Enable tiered shielding to avoid origin thundering herd at 3am
    set beresp.http.CDN-Cache = "HIT-PREFETCH";
  }
}

# Android WorkManager: defer until idle + unmetered if possible
val prefetchWork = OneTimeWorkRequestBuilder<PrefetchWorker>()
  .setConstraints(Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)
    .setRequiresBatteryNotLow(true)
    .setRequiresStorageNotLow(true)
    .build())
  .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES)
  .addTag("cdn-prefetch")
  .build()

How to Tune Battery, Bandwidth, and Freshness Without Guessing

There's always a trade-off. Fetch too often in the background and you'll drain battery and annoy users; fetch too rarely and the cache is stale when they open the app on the subway. I track three numbers for every app I advise: average battery cost per background fetch, cellular bytes saved by serving from disk vs. network, and cache hit rate for cold opens. You can't optimize what you don't measure, and the CDN edge logs are usually more honest than client analytics about what was actually served.

What’s worked best for content-heavy apps is a smart prefetch policy: small, versioned deltas at night on Wi-Fi, and nothing larger than 5MB per idle window on cellular. We use ETag and If-None-Match so 304 responses cost almost nothing, and we pause any background transfer if the OS reports thermal pressure. For apps that need to work completely offline, you’ll want to pair this with a persistent manifest that survives app restarts — the pattern in Offline-First Architecture with CDN: Building Apps That Work Without Connectivity is exactly how we built a magazine app that could open in airplane mode with 100% of yesterday’s content ready.

Don't guess at the thresholds. Run an A/B test on your prefetch interval and look at real device metrics. I've found that fetching every 6 hours on Wi-Fi with delta patches beats a single nightly bulk download by a wide margin, both for freshness and for the OS not penalizing you. The table below shows typical numbers I’ve seen across three mid-size media apps after we tuned their idle fetching with the edge — these aren't lab results, they're from production on iOS 17 and Android 14 devices.

Prefetch Strategy (Idle Window) Avg. Battery Drain / 50MB Cellular Data Saved vs. Foreground Cold Open Cache Hit Rate
Foreground fetch on launch (no background) 4.2% (high CPU + radio wake) 0% baseline 38%
Scheduled idle fetch, full asset re-download 2.8% 18% 71%
Idle fetch with delta + 206 Range resume 1.1% 62% 89%
Idle fetch with stale-while-revalidate at edge 0.7% 74% 94%

Practical Implementation: Building an Idle-Aware Fetch Pipeline That Actually Works

Here's where most teams get it wrong - they take their foreground networking stack and just slap it inside a BGProcessingTask or WorkManager job. That'll get you throttled in about two days. The OS gives you a brutal window: iOS will grant you roughly 30 seconds of actual execution time per background wake, and if you overrun it or burn more than ~1-2% battery per wake, your app's background budget gets slashed for the next 24-72 hours. Android's not much more forgiving - WorkManager with NetworkType.UNMETERED and RequiresBatteryNotLow constraints will defer you indefinitely if you don't play nice.

The trick is to make every idle fetch conditional and cheap. Don't re-download a 12MB asset bundle if the ETag hasn't changed. Your CDN should be configured with a short edge TTL (say, max-age=900) plus a long stale-while-revalidate=86400, so idle requests hit edge cache 95%+ of the time without ever reaching origin. On the client, you store the last ETag and Last-Modified per asset and send If-None-Match / If-Modified-Since. When the CDN returns a 304 Not Modified - which should be 40-60% of your idle wakes if your content isn't changing hourly - you pay ~800 bytes of header overhead instead of megabytes. That's how you stay under the OS radar.

You also can't fetch the same quality for everyone. An idle fetch on a locked phone with 2 bars of LTE shouldn't pull a 4K image carousel. We wire our background session to check CTRadioAccessTechnology / ConnectivityManager before choosing an asset variant, and let the edge pick via Client Hints or a CH-Width + DPR Vary. If you're building this right, you'll want to look closely at Adaptive Quality Delivery for Mobile: Network-Aware Content Optimization - it covers how we serve a 180KB variant instead of a 1.4MB one when the OS reports isExpensive or isConstrained. Pair that with predictive prefetching so you're only fetching what the user is 70%+ likely to open next, not your entire catalog. Our logic for that is detailed in Mobile CDN Prefetch Strategies: Predicting and Preloading Content Before Users Need It, but the rule of thumb is: fetch max 3-5 assets, 5-10MB total per wake, and prioritize JSON/config over heavy media.

// iOS 17+ - Idle fetch with conditional CDN request
import BackgroundTasks

func scheduleIdleFetch() {
    let request = BGProcessingTaskRequest(identifier: "com.app.cdn.refresh")
    request.requiresNetworkConnectivity = true
    request.requiresExternalPower = false
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // don't spam
    BGTaskScheduler.shared.submit(request)
}

func handleIdleFetch(task: BGProcessingTask) {
    let config = URLSessionConfiguration.background(withIdentifier: "cdn-idle")
    config.isDiscretionary = true // let OS wait for WiFi/power
    config.sessionSendsLaunchEvents = true
    
    let session = URLSession(configuration: config)
    var req = URLRequest(url: URL(string: "https://cdn.example.com/v2/feed.json")!)
    // Conditional request - saves 99% of bandwidth on 304
    if let etag = UserDefaults.standard.string(forKey: "feed-etag") {
        req.addValue(etag, forHTTPHeaderField: "If-None-Match")
    }
    req.addValue("1", forHTTPHeaderField: "X-Idle-Fetch") // for CDN analytics
    
    task.expirationHandler = { session.invalidateAndCancel() }
    
    session.dataTask(with: req) { data, response, error in
        guard let http = response as? HTTPURLResponse else { return }
        if http.statusCode == 304 {
            // Not modified - update age, do nothing
            task.setTaskCompleted(success: true)
            return
        }
        if let etag = http.allHeaderFields["Etag"] as? String {
            UserDefaults.standard.set(etag, forKey: "feed-etag")
        }
        // Cache to disk, verify Age / X-Cache: HIT
        task.setTaskCompleted(success: true)
    }.resume()
}

Advanced Patterns and Troubleshooting When Idle Fetches Fail

Once you're in production, you'll see three failure modes over and over. First is the thundering herd. If you push a new asset version and 200k devices all wake up at 2am and miss cache, your origin will get hammered. We solve this with request coalescing at the edge (origin shield + stale-while-revalidate) and by adding jitter to your schedule - don't use earliestBeginDate = now + 15m for everyone. Add arc4random_uniform(900) seconds of jitter. On the CDN, set Surrogate-Key purging so you can soft-purge instead of hard invalidations; that keeps your hit ratio above 92% even during a rollout.

Second is silent throttling. Your logs will show background fetches just stopped firing, and there's no crash. On iOS, check Settings > Developer > BackgroundTask Debug - if your app's BGTaskSchedulerPermittedIdentifiers budget is exhausted, you won't be scheduled again until the user foregrounds you. Common cause? You fetched on cellular when you said you wouldn't, or you exceeded 30s wall time 3 times in a row. On Android, adb shell dumpsys jobscheduler will show STOP_REASON_CONSTRAINT_CONNECTIVITY or BATTERY. We log every idle wake with X-Cache, Age, bytes transferred, and battery level so we can correlate. And this is where Mobile Connection Detection and Switching: Adapting CDN Behavior to Network Type becomes critical - if your CDN edge can sniff ECT: 4g vs 2g via Network Information headers and return a 204 No Content for heavy assets on slow links, you save the client from making a bad decision.

Finally, don't try to download large bundles atomically. If you need to update a 40MB offline pack, chunk it. Use Range: bytes=0-524287 and resume across multiple wakes. We've seen completion rates jump from 58% to 94% by switching from single 30MB fetches to six 5MB ranged chunks. Pair that with delta manifests - fetch a 12KB JSON diff, then only pull the changed chunks. If you're seeing lots of NSURLErrorBackgroundSessionWasDisconnected or WorkManager Result.retry() loops, implement exponential backoff starting at 15 minutes with 30% jitter, and cap retries at 3. Anything more and you're just burning the user's battery for no reason - just wait for the next natural foreground.

Frequently Asked Questions

How much data can I safely fetch in the background without getting throttled?

Keep it under 10MB per wake and under 30 seconds of active transfer. In our fleet data, apps that averaged 4-7MB per background wake on WiFi kept a 98% schedule success rate on iOS. Push it past 20MB or 45 seconds and your success rate drops below 60% within a week because the OS penalizes you. On metered connections, drop that to 1-2MB max or defer entirely with isDiscretionary = true.

Do conditional requests (ETag / 304) actually save that much in the real world?

Absolutely. For a news or catalog app where content changes 3-4 times per day, 55-65% of idle wakes will return 304. That turns a 2.8MB fetch into ~0.8KB of headers. At the edge, a 304 is also 10-15x cheaper in CPU time than a full HIT. Make sure your CDN forwards If-None-Match correctly and doesn't strip ETags - Cloudflare, Fastly, and Akamai all do this by default if you don't set Cache-Control: private.

Should I treat WiFi and cellular idle fetches differently?

Yes, and you must. Schedule two tiers: a lightweight config/JSON refresh that can run on any connection (capped at ~500KB) and a heavy asset prefetch that requires unmetered + battery > 30%. Your CDN should vary on Save-Data and ECT hints - if the client sends Save-Data: on, the edge should return a lower-quality variant or a 204. Don't rely solely on client checks; the edge is your safety net.

What's the difference between prefetching and background idle fetching?

Prefetching is predictive - you guess what the user wants next and fetch it speculatively, ideally while they're still in the foreground but idle. Background idle fetching is opportunistic - the OS wakes you when conditions are good (charging, WiFi) and you refresh what you already have. The best strategy is both: use idle fetches to keep your cache warm with ETag validation, and use foreground prefetching for the next 2-3 screens. Don't use background wakes to blindly prefetch uncached content - you'll waste the budget.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker