Migrate On-Demand Resources To Localized Managed Background Assets

The fastest way to make a great app feel heavy is to ship every possible asset on day one.

Games do this with levels, textures, cinematics, voice lines, and premium chapters. Education apps do it with offline courses and tutorial videos. Travel apps do it with maps. Language apps do it with speech packs. AI features do it with optional ML models. Media apps do it with downloadable templates, sound libraries, or creator packs.

For years, the usual escape hatch was either a custom CDN downloader, On-Demand Resources, or a big app bundle with a nervous explanation that “the first install is large, but everything works offline.” Managed Background Assets gives App Store apps a cleaner path: keep the base app small, let the system download asset packs when they are needed, and let Apple host the packs for apps distributed through the App Store.

Apple-hosted Background Assets started with iOS, iPadOS, macOS, tvOS, and visionOS 26. Apple says App Store apps can use up to 200 GB of hosted assets per app as part of Developer Program membership. In iOS 27, Managed Background Assets adds localized asset packs, which is the missing piece for apps that currently ship every voice-over, video, or tutorial in every language.

This post is a migration guide, not a recap. The goal is to move from “everything is in the app” or “we have old ODR-style content packs” to an asset delivery model that feels intentional to the user.

Decide what belongs where

Before touching manifests, sort your content into four buckets.

Bundle: content that must be available at first launch, without network access, and without waiting. Keep this small. It usually includes launch UI, a tutorial shell, placeholder art, a playable first slice, default strings, and a tiny fallback experience.

Essential pack: content the app should make available very early, but that does not need to increase the App Store download size. For a game, this might be the first world after the tutorial. For an education app, it might be the first lesson in the user’s chosen course.

Prefetch pack: content the user is likely to need soon. A game can prefetch the next level while the current level is being played. A travel app can prefetch nearby offline map tiles after the user saves a trip. A course app can prefetch the next module when the current one is nearly done.

On-demand pack: content that should only download after an explicit action or entitlement check. Examples include premium game chapters, optional language voice packs, a high-resolution map region, a large ML model, downloadable creator templates, or a semester-long course the user has not opened yet.

Do not treat this as a storage problem only. It is a product behavior problem. Users should see progress before content is revealed, understand why a download is happening, and still have something useful to do if the download is unavailable.

Good rules of thumb:

  • Keep a small fallback experience in the bundle.
  • Never show a locked or empty screen while a pack silently downloads.
  • Let the user start an on-demand download from a clear action.
  • Prefetch only when the next action is predictable.
  • Localize only language-specific content.
  • Keep shared files shared.

That last point matters. If a level uses the same meshes in every language but different voice lines, do not duplicate the meshes into every localized pack. Put the language-neutral content in a shared pack and put only the voice, subtitles-as-media, dubbed tutorial videos, or language-specific examples in localized packs.

Audit the current assets

Start with a boring spreadsheet or JSON file. You need to know what you have before you can decide how it should move.

[
  {
    "path": "Assets/Levels/World01",
    "currentDelivery": "bundle",
    "proposedPackID": "world-01",
    "policy": "essential",
    "language": null,
    "uncompressedSizeMB": 820,
    "owner": "gameplay"
  },
  {
    "path": "Assets/Voice/en-US/Tutorial",
    "currentDelivery": "odr",
    "proposedPackID": "voice-tutorial",
    "policy": "on-demand",
    "language": "en-US",
    "uncompressedSizeMB": 240,
    "owner": "narrative"
  }
]

For an iOS app, I like to audit these categories:

  • Game levels, arenas, cutscenes, textures, shaders, and downloadable worlds.
  • Language voice packs, pronunciation examples, dubbed tutorial videos, and audio descriptions.
  • Tutorial videos, onboarding media, and help content.
  • ML models, tokenizers, embeddings, and model-specific resources.
  • Offline maps, route data, venue maps, and city guides.
  • Premium chapters, templates, content libraries, and creator packs.
  • Education courses, labs, quizzes, PDFs, videos, and exercises.

Then name packs by user-facing ownership, not by incidental folder names. voice-english is easier to reason about than AudioPack7. city-paris-map is easier than Region_0042.

Also decide whether your pack IDs are stable forever. If saved games, course progress, or entitlement records refer to pack IDs, changing them later becomes a migration of its own.

Convert old ODR thinking into Background Assets policies

On-Demand Resources trained many teams to think in tags. Background Assets pushes you toward asset packs with a download policy and a manifest.

The practical translation looks like this:

Old shapeNew shape
App bundle contains all contentBundle only the minimum fallback and move optional content into packs
ODR tag for a levelAsset pack for that level or chapter
ODR tag for each languageLocalized asset pack with a language tag
Custom CDN manifestBackground Assets manifest plus App Store Connect hosting
Paywall unlock downloads files manuallyStoreKit unlock enables content, then Background Assets makes the pack available

Keep StoreKit as an entitlement gate, not an asset transport. If the user buys a premium course, your app should verify the entitlement, then request the matching asset pack. The UI should not reveal the course until the entitlement exists and the pack is locally available.

Create localized asset pack manifests

In iOS 27, localized asset packs use the user’s preferred language from Settings. If a pack for that language is unavailable, the system falls back to the closest language match. If there is no similar regional variant, it falls back to the app’s primary language.

That means you should create localized packs for content that actually changes by language:

  • Voice-over audio.
  • Dubbed tutorial videos.
  • Speech recognition examples.
  • Language-specific lesson media.
  • Pre-rendered text inside images or videos.

Do not localize content just because it belongs to a localized feature. A puzzle texture, shared game level, map tile, or model weight file is usually language-neutral.

Here is the shape Apple showed for a localized asset pack manifest, combined with the download-policy schema from the WWDC25 Background Assets session. For an on-demand pack, use the onDemand policy key with an empty object. Essential and prefetch policies use installation-event configuration instead.

{
  "assetPackID": "voice-tutorial-en-US",
  "downloadPolicy": {
    "onDemand": {}
  },
  "language": "en-US",
  "sourceRoot": "Assets/Voice/en-US/Tutorial",
  "fileSelectors": [
    { "directory": "." }
  ],
  "platforms": [
    "iOS"
  ]
}

And the matching German pack:

{
  "assetPackID": "voice-tutorial-de-DE",
  "downloadPolicy": {
    "onDemand": {}
  },
  "language": "de-DE",
  "sourceRoot": "Assets/Voice/de-DE/Tutorial",
  "fileSelectors": [
    { "directory": "." }
  ],
  "platforms": [
    "iOS"
  ]
}

Notice the conservative naming: each packaged variant gets a stable asset-pack ID, and the language tag tells the system how that variant participates in localization and fallback. WWDC26 confirms language tags and fallback behavior, but it does not prove that multiple manifests may reuse the same assetPackID. Until the current App Store Connect and Background Assets documentation says otherwise, keep pack IDs unique and maintain an app-owned mapping from a logical content concept like tutorial voice to the asset-pack IDs you publish.

Use BCP 47 language identifiers such as en-US, en-GB, fr-FR, and de-DE. Make your primary language pack complete, because it is the final fallback when the user’s selected language has no close match.

Package packs with ba-package

Xcode 27 includes ba-package tooling. If you already have Steam depots or depot-style manifests, Apple showed a converter that can create an asset pack manifest:

xcrun ba-package convert --asset-pack-id voice-english -l en-US --on-demand voice-english.vdf -o voice-english.json

Then package the manifest into an asset pack archive:

xcrun ba-package voice-english.json -o voice-english.aar

For apps that do not have Steam depots, the same migration shape still applies:

  1. Generate or hand-author one manifest per pack variant.
  2. Keep source assets in deterministic folders.
  3. Package each manifest into an archive.
  4. Store the generated archives somewhere your debug scheme and release process can find.
  5. Upload the release archives for App Store Connect hosting.

A simple folder layout might look like this:

AssetPacks/
  manifests/
    voice-tutorial-en-US.json
    voice-tutorial-de-DE.json
    world-01.json
    paris-map.json
  packaged/
    voice-tutorial-en-US.aar
    voice-tutorial-de-DE.aar
    world-01.aar
    paris-map.aar

Add a validation step before packaging. Check that every localized pack contains the same required logical files, that the primary language exists, and that shared content did not accidentally get copied into every language.

xcrun ba-package AssetPacks/manifests/voice-tutorial-en-US.json \
  -o AssetPacks/packaged/voice-tutorial-en-US.aar

xcrun ba-package AssetPacks/manifests/voice-tutorial-de-DE.json \
  -o AssetPacks/packaged/voice-tutorial-de-DE.aar

Treat these archives like build artifacts. They should be reproducible from source assets and manifests, not manually edited after packaging.

Wire the native app target

Packaging is only half the migration. A native Swift app also needs the Background Assets integration that lets the system coordinate downloads with your app.

The WWDC25 native setup includes a downloader extension. For managed packs, Apple provides the store-backed extension protocol shape:

import BackgroundAssets
import ExtensionFoundation
import StoreKit

@main
struct DownloaderExtension: StoreDownloaderExtension {
    func shouldDownload(_ assetPack: AssetPack) -> Bool {
        true
    }
}

Put the app and extension in the same app group, then add the Background Assets keys Apple calls out in the app’s Info.plist:

<key>BAAppGroupID</key>
<string>group.com.example.yourapp</string>
<key>BAHasManagedAssetPacks</key>
<true/>
<key>BAUsesAppleHosting</key>
<true/>

That setup is easy to gloss over, but it is the difference between “we created .aar files” and “the app can actually ask the system for hosted managed packs.” Treat the exact target template, entitlements, and key names as current-documentation items when you update to each Xcode seed.

Put a small asset gate in Swift

Your app should not scatter Background Assets checks throughout SwiftUI views. Put a small gate between product state and content presentation.

Because the native API surface can still be beta-shaped, the following Swift keeps app-owned protocol names around the source-backed operations: find an AssetPack, listen for statusUpdates, call ensureLocalAvailability(of:), then read files through AssetPackManager.shared.contents(at:) or descriptor(for:). Do not model the result as a directory URL unless the SDK you are using explicitly exposes one; Background Assets can merge pack contents into a managed namespace.

import Foundation

enum ManagedAssetState: Equatable {
    case bundledFallback
    case notRequested
    case checking
    case downloading(progress: Double)
    case ready
    case unavailable(message: String)
}

struct ManagedAssetRequest: Hashable {
    let packID: String
    let reason: String
}

protocol BackgroundAssetInstalling {
    func prepare(packID: String) -> AsyncThrowingStream<ManagedAssetState, Error>
    func data(at path: String, searchingIn packID: String) throws -> Data
}

The feature model can then expose one simple state to the UI:

import SwiftUI

@MainActor
final class ContentAssetGate: ObservableObject {
    @Published private(set) var state: ManagedAssetState = .notRequested

    private let installer: BackgroundAssetInstalling

    init(installer: BackgroundAssetInstalling) {
        self.installer = installer
    }

    func openContent(packID: String) {
        state = .checking

        Task {
            do {
                for try await update in installer.prepare(packID: packID) {
                    state = update
                }
            } catch {
                state = .unavailable(message: "This content could not be downloaded right now.")
            }
        }
    }
}

Your real implementation might look up the pack with AssetPackManager.shared.assetPack(withID:), monitor statusUpdates(forAssetPackWithID:), call ensureLocalAvailability(of:), and then read the needed file with contents(at:searchingInAssetPackWithID:options:) or descriptor(for:searchingInAssetPackWithID:). The current SDK uses System.FilePath for managed pack paths, so convert app-owned string paths deliberately before calling the framework API. The important part is the product contract:

  • The view starts in a playable or useful bundled fallback.
  • The user takes an action.
  • The app checks entitlement when needed.
  • The app requests the pack.
  • The app shows progress.
  • The app reveals the content only after the pack is local.

For a premium chapter, the sequence is:

@MainActor
func openPremiumChapter(id: String) async {
    guard await entitlements.contains("premium.chapter.\(id)") else {
        showPaywall(for: id)
        return
    }

    assetGate.openContent(packID: "chapter-\(id)")
}

For a language voice pack, do not ask the user to pick a language variant unless that is truly part of your product. Keep an app-owned content identifier, map it to the asset-pack IDs and languages you publish, and verify in the current SDK how localized pack resolution is exposed to app code.

Design the progress state

The worst migration is technically correct and emotionally weird: the user taps a course, the app navigates to a blank screen, then a spinner appears with no context.

Make progress part of the content card before navigation.

For example:

  • A game level button shows “Downloading voice pack, 34%” before becoming playable.
  • A language lesson shows the bundled text-only version while the high-quality voice pack downloads.
  • An offline map region shows its download size, progress, and “available offline” state.
  • An ML feature explains that the model runs on device and downloads only after opt-in.
  • A premium education course stays locked until purchase, then changes to “Preparing course, 62%.”

Also decide what cancellation means. If the user leaves the screen during a prefetch, you may continue. If the user explicitly started a huge offline map download on cellular, you probably need a visible cancel action and a resumable state.

Test with the Xcode 27 mock server

Do not wait for App Store Connect hosting to test the user experience.

There are two testing paths to keep straight.

For the Unity plug-in workflow shown at WWDC26, Xcode 27 can start a Background Assets mock server as part of the debug session after the scheme points at the packaged asset folder.

For a native Background Assets integration, the WWDC25 flow is more explicit: run the local mock server tooling, configure HTTPS/certificates, and set the mock server base URL in Development Overrides on your test device. That path is more setup, but it tests the same app-side state machine before you upload packs to App Store Connect.

Test the unhappy paths, not just the perfect path:

  • First launch with no packs downloaded.
  • Opening on-demand content on a slow network.
  • Progress updates while the app is foregrounded.
  • Leaving and returning during a download.
  • Missing localized pack for en-GB when en-US exists.
  • Missing selected language, falling back to the app’s primary language.
  • Missing or malformed pack archive.
  • Reinstalling the app.
  • Updating from a build that used bundled or ODR-style assets.
  • A purchased entitlement where the asset pack is not yet local.

For App Store Connect hosting, test with the same pack IDs, languages, and policy assumptions you used locally. Local serving proves your app’s state machine. App Store Connect testing proves your release packaging, hosted asset configuration, and version-selection behavior.

Migrate in slices

Do not move everything in one release.

A safer migration plan looks like this:

  1. Add the asset gate while content still exists in the bundle.
  2. Move one low-risk optional pack out of the bundle.
  3. Test the mock server with progress, failure, and fallback.
  4. Upload that pack for hosted delivery.
  5. Measure install size, first-use latency, and support issues.
  6. Move the next pack category.
  7. Only then migrate high-value premium or progression-critical content.

Games should start with optional voice packs, tutorial videos, or later levels. Education apps should start with a non-required course. Travel apps should start with one offline city. AI apps should start with an optional model, not the model that powers the first-launch experience.

For ODR migrations, keep a compatibility layer for at least one release if users may have older content state. The app should be able to translate “ODR tag was available” into “new asset pack should be considered available or revalidated” without losing progress.

Pitfalls to avoid

  • Duplicating shared assets into every localized pack.
  • Making the first screen depend on an on-demand download.
  • Revealing premium content before both entitlement and asset availability are true.
  • Treating progress as a spinner instead of a product state.
  • Forgetting the primary language fallback pack.
  • Naming packs after build folders instead of content concepts.
  • Changing pack IDs without a migration plan.
  • Testing only with perfect network conditions.
  • Skipping the mock server until the end.
  • Moving too much content in one release.

The official source trail for this migration is Apple’s WWDC26 session “Unlock in-game content with StoreKit and Background Assets,” the Apple Background Assets documentation, and WWDC25’s “Discover Apple-Hosted Background Assets.” The WWDC26 session is especially useful for the current iOS 27 localized pack behavior, the language manifest tag, ba-package, the mock server, and the way StoreKit can sit beside asset delivery for paid content.

Checklist

Before you ship, make sure this is true:

  • The app bundle contains a small, useful fallback.
  • Every pack has a stable assetPackID.
  • Language-specific packs include a language tag.
  • Localized variants use pack IDs that match the current App Store Connect and Background Assets identity rules.
  • The primary app language pack is complete.
  • Shared content is not duplicated into localized packs.
  • Essential, prefetch, and on-demand policies are intentional.
  • The app and downloader extension share the right app group and Background Assets Info.plist keys.
  • The UI shows progress before revealing downloaded content.
  • Paid content checks StoreKit entitlement separately from asset availability.
  • The native local-testing path or Xcode 27 plug-in mock server has been tested with packaged archives.
  • App Store Connect hosting has been tested before release.
  • Updates from old bundled or ODR-style content have a migration path.

This is the mindset shift: your app bundle should contain the promise of the experience, not every byte the experience could ever need. Managed Background Assets lets you deliver the rest at the moment it becomes useful. Localized packs make that feel much sharper, because a user who plays in German, studies in French, or downloads an English course should not pay the storage cost for every other language you support.