Skip to main content
A flow normally resolves over the network the first time you present it, which can add a short delay. If you know a placement is coming (onboarding at launch, a paywall behind a known button), warm it up ahead of time so a later present is served from cache with no network round-trip. There are three ways to warm placements:
  • prefetch(_:warmMedia:) warms one or more placements on demand and tells you, per placement, exactly what is now ready.
  • prefetchOnLaunch declares placements to warm automatically in the background right after configure(...).
  • isPlacementReady(_:) checks whether a flow is available, and leaves it warm as a side effect.

prefetch

@discardableResult
public func prefetch(
    _ placementKeys: [String],
    warmMedia: Bool? = nil
) async -> [String: PrefetchOutcome]
prefetch resolves each placement key and caches the result (the flow JSON, in memory and on disk) plus any custom fonts the flow uses, so a later present can serve from cache instead of waiting on the network. It runs the keys concurrently, de-duplicates the input, and never throws: instead of swallowing failures, it returns a [String: PrefetchOutcome] so you can see, per placement, what is warm and what failed, without a second round-trip. prefetch is not @MainActor; call it from any async context (for example a launch Task).
import FlowPilotSDK

// At app launch, after configure(...).
Task {
    let outcomes = await FlowPilot.shared?.prefetch(["onboarding", "paywall"])

    for (key, outcome) in outcomes ?? [:] {
        switch outcome.state {
        case .warmed(let fromCache):
            // A presentable flow is cached. `fromCache` is true when it came from
            // a local cached copy, false when freshly resolved or bundled.
            print("\(key): ready (fromCache: \(fromCache), media: \(outcome.mediaWarmed))")
        case .noFlow:
            // Resolve succeeded but the backend has nothing to show here.
            print("\(key): no flow")
        case .failed(let error):
            // Resolve failed and no fallback was available; nothing warmed.
            print("\(key): failed (\(error))")
        }
    }
}
The result is @discardableResult, so a bare await prefetch([...]) (ignoring the return value) keeps working unchanged.

Warming media

By default a bare prefetch([...]) warms the flow JSON and fonts only, not images. To also warm images, opt in with warmMedia: true:
await FlowPilot.shared?.prefetch(["onboarding"], warmMedia: true)
When warmMedia is true, how many images are warmed is bounded by prefetchMediaStrategy (first screen by default; see the table under Prefetch at launch). Media warming is best-effort: a failed image download never fails the prefetch, and PrefetchOutcome.mediaWarmed tells you whether it ran. See Media preloading for how images are otherwise loaded at present time.

PrefetchOutcome

public struct PrefetchOutcome: Sendable {
    public enum State: Sendable {
        case warmed(fromCache: Bool)   // a presentable flow is cached and ready
        case noFlow                    // resolve ok, backend has nothing to show
        case failed(FlowPilotError)    // resolve failed; nothing warmed
    }
    public let placementKey: String
    public let state: State
    public let mediaWarmed: Bool       // first/all-screen images warmed
    public var isWarmed: Bool          // convenience: true when state is .warmed
}
FieldMeaning
placementKeyThe placement this outcome is for.
state.warmed(fromCache:), .noFlow, or .failed(_). See below.
mediaWarmedtrue when images were warmed into the image cache as part of this prefetch. Always false unless you requested media warming and the flow is presentable.
isWarmedConvenience: true when state is .warmed.
For .warmed(fromCache:), fromCache is true when the flow came from a local cached copy (a fresh hit or a last-known-good stale entry) without a fresh network resolve, and false when it was freshly resolved from the network or served from a bundled default.

Prefetch at launch

Declare placements to warm automatically right after configure(...). Warming runs in the background at utility priority and never blocks startup.
FlowPilot.configure(
    FlowPilotConfiguration(
        apiKey: "fp_live_xxxxxxxxxxxxxxxx",
        appId: "your-app-id",
        prefetchOnLaunch: ["onboarding", "paywall"],
        prefetchMediaStrategy: .firstScreen   // .none | .firstScreen (default) | .allScreens
    )
)
Unlike a bare manual prefetch([...]), launch prefetch does warm images by default, governed by prefetchMediaStrategy:
StrategyWarms
.noneflow JSON + fonts only
.firstScreen (default)+ first-screen and persistent-zone (navigation bar, footer, overlay) images
.allScreens+ every screen’s images
With two or more placements declared, the SDK warms them in a single batch round-trip (POST /apps/{appId}/placements/resolve-batch), then falls back to per-placement resolves if that endpoint is unavailable. Either way the result is the same warmed cache.
Launch prefetch needs caching on and a non-zero TTL to do anything.
  • It is a no-op when cachingEnabled == false (nothing would be retained); the SDK logs a warning.
  • A warmed flow only lives as long as its freshness TTL (driven by the resolve response’s cache_ttl_seconds). The .development and .custom environments disable HTTP caching, so against a backend that returns a 0 TTL a warmed entry expires immediately and prefetch has no visible effect. Use .staging / .production (or a backend that returns a non-zero TTL) to see the benefit. See Caching.

isPlacementReady

public func isPlacementReady(_ placementKey: String) async -> Bool
isPlacementReady returns true if a flow is available for the placement. It checks the cache first (a cache hit returns true without a network call); if there is no cache, it performs a live resolve. It is not @MainActor and never throws (a failed resolve returns false). Crucially, the resolve it performs is not wasted: it routes through the same cache-populating path as prefetch, so a presentable flow is left warm and the following presentPlacement hits the cache with no second round-trip.
if await FlowPilot.shared?.isPlacementReady("onboarding") == true {
    FlowPilot.shared?.presentPlacement("onboarding")   // served from cache
}
Use it to decide whether to show an entry point at all, for example only enabling an “Upgrade” button when a paywall flow exists.
struct UpgradeButton: View {
    @State private var session: FlowSession?
    @State private var canShow = false

    var body: some View {
        Button("Upgrade") {
            Task {
                session = try? await FlowPilot.shared?.createSession(placementKey: "paywall")
            }
        }
        .disabled(!canShow)
        .flowPresenter(session: $session)
        .task {
            canShow = await FlowPilot.shared?.isPlacementReady("paywall") ?? false
        }
    }
}

Identity-correct caching

A resolved flow is personalized by the user identity and targeting attributes it was resolved under. The cache reflects this: a warmed flow is only reused for the same identity and attributes it was prefetched with. If you change the user (for example after login) or update targeting attributes via updateContext, the next present resolves fresh rather than serving the previously warmed flow. This keeps targeting and experiment assignment correct: a flow warmed while anonymous is never shown to a since-identified user. The stale-cache offline fallback (Tier 2) still works across an identity change. See Caching.

In-flight coalescing

Concurrent resolves for the same placement and identity share a single network call. If you prefetch a placement and then presentPlacement it before the prefetch finishes (or fire two prefetches for the same key), only one resolve goes to the network; the others await the same result. A caller hitting its own resolveTimeout does not cancel the shared resolve, so it still completes and populates the cache for the next caller.

When to prefetch

  • At app launch, with prefetchOnLaunch, for the first placement the user will hit (often onboarding).
  • Before a known entry point, for example call prefetch(["paywall"]) when the user lands on a screen that has an upgrade button.
  • Not everything. Each key is a resolve (a network call when not cached). Prefetch only what you are reasonably likely to show.

Notes and warnings

  • Best-effort, not a guarantee. A .warmed outcome or a true from isPlacementReady is a strong hint, not a promise: conditions can change between the check and the present (context updates, experiment bucketing, a paused placement), so the present can still resolve differently. Always handle the “no flow” path at present time (catch the error or use the fallback overload).
  • Readiness can be false offline. If a placement is not cached and the device is offline, the live resolve fails and isPlacementReady returns false, even if a bundled default would let the present succeed.
  • Media warming is bounded and best-effort. It respects the image cache’s concurrency cap, skips images already cached, and never fails the prefetch. Lottie and video assets are not warmed.

Common mistakes

  • Prefetching everything. Each key triggers a resolve. Prefetch only the placements you expect to present soon.
  • Expecting a bare prefetch to warm images. A bare prefetch([...]) warms JSON and fonts only. Pass warmMedia: true, or use prefetchOnLaunch (which warms media per prefetchMediaStrategy), to remove first-screen image pop-in.
  • Assuming readiness means a flow will show. isPlacementReady reflects the moment you call it. Still handle a missing flow when you present (use the fallback overload or catch the error).
  • Calling isPlacementReady repeatedly to poll. Check it once at the point you need the decision. (The first call leaves the placement warm, so the following present is cheap.)
  • Expecting launch prefetch to work in development. With .development (or a 0-TTL backend), warmed entries expire immediately. Test launch prefetch against .staging / .production.