Skip to content

Interstitial ads

How do I show an interstitial ad in Compose Multiplatform?

Section titled “How do I show an interstitial ad in Compose Multiplatform?”

Interstitial, rewarded, rewarded interstitial and app-open controllers all implement FullScreenAdController, whose surface is load(), show(), isReady(), preload(), availability(), clear(), loadState: StateFlow<AdLoadState>, events: SharedFlow<AdEvent> and placement. The interstitial integration:

val placement = remember {
AdPlacement(
id = "interstitial_main",
format = AdFormat.Interstitial,
androidAdUnitId = TestAdIds.ANDROID_INTERSTITIAL,
iosAdUnitId = TestAdIds.IOS_INTERSTITIAL,
)
}
val interstitial = remember(adManager) { adManager.interstitial(placement) }
val scope = rememberCoroutineScope()
// Preload, well before the moment you want to show
scope.launch {
when (val state = interstitial.load()) {
is AdLoadState.Loaded -> println("ready: ${state.responseInfo?.responseId}")
is AdLoadState.Failed -> println("load failed: ${state.error}")
else -> Unit
}
}
// Show — suspends until the ad is dismissed
scope.launch {
when (val result = interstitial.show()) {
is AdShowResult.Shown -> println("shown and dismissed")
is AdShowResult.NotReady -> println("call load() first")
is AdShowResult.Failed -> println("show failed: ${result.error}")
}
}

Two rules that matter. Wrap the controller lookup in remember because controllers are placement-keyed and cached. And call show() from a UI-scoped coroutine and never GlobalScope, because it suspends for the ad’s full on-screen lifetime.

The remember(adManager) form (rather than remember { … }) is the common shape: it is keyed on the manager so the controller is rebuilt if the manager is replaced, but not on every recomposition. The placement itself is remember-ed separately so its id is stable across recompositions of the same screen. A typical app also owns the placement list as a val in a CompositionLocal or a top-level object so every screen shares the same AdPlacement instances.

The three-case sealed result explicitly:

  • AdShowResult.Shown — the ad was presented and has now been dismissed. This is not “the ad started”; it is “the ad finished.” It is the only result on the success path.
  • AdShowResult.NotReady — there was nothing to show, or a presentation is already active on this controller. This is the no-op result; do not retry immediately.
  • AdShowResult.Failed(error) — the SDK started a presentation but the platform rejected it. The AdError carries code, message, domain and responseInfo.

Branching on the result is the right shape; do not assume the ad shown just because the call returned. A Failed result is rare but real, usually caused by an iOS ATT denial that lands between the show() call and the platform’s present hook, or by a race with another presentation on the same controller.

A practical point on Shown: it is emitted after the platform SDK fires its didDismissFullScreenContent / onAdDismissedFullScreenContent callback. The coroutine that called show() resumes at that point. A common shape is to use Shown as the trigger to launch the next screen, hide the loading indicator, or restore UI that was disabled during the presentation.

Full-screen ad lifecycle: load, cache, show, reloadThe FullScreenSlotCore state machine, its TTL'd FIFO cache, the generation counter bumped by clear(), and why show() is not reentrant per controller.IdleLoadingAdRetry backoffLoadedadmitted to the cachePresentingSDK owns the screenload()successshow()FIFO · maxSize (default 1) · TTL 1h, app-open 4hload failedFailedload() again to retrypresentAd suspendsuntil dismissalDismissedSDK terminal callbackreloadAfterShow?yes → scheduleReloadnoIdle if the cache is empty,Loaded if it is notclear() and the generation counterclear() bumps slotState.generation andretires the cached ads.A load or scheduled reload carries thegeneration it started with and re-checksit before publishing, so it can neverrepopulate a cache the caller just emptied.Presentation ownershippresentAd suspends until dismissal. Apresented ad is destroyed on normal returnonly — never on cancellation.FullScreenPresentationHandle is one-shot:after tryHandOffToCallbacks() the SDK ownsthe presentation and it is never force-closed.show() is not reentrant per controller — asecond call returns NotReady, not a queue.Every suspending operation is bounded: withTimeoutOrNull(loadTimeout) wraps the whole attempt sequence, retry backoff included.CLAUDE.md invariants #1, #2 and #9.
Full-screen ad lifecycle: load, cache, show, reloadCLAUDE.md invariants #1, #2, #9Read this diagram in words

The state machine the diagram shows, in prose: AdLoadState moves Idle → Loading → Loaded(responseInfo) or Failed(error). Loaded ads enter a TTL’d FIFO cache. show() consumes the oldest first. Expired entries are evicted on access. clear() bumps a generation counter so a load or scheduled reload started before the clear() can never repopulate a cache the caller just asked to be emptied.

The generation counter is the part that is hard to see from the public API but matters in practice: without it, an in-flight load() issued before clear() could race with the clear and leave the cache populated when the caller expected it empty. With it, the load checks the generation before publishing its result and discards it on a mismatch. The same mechanism protects reloadAfterShow from re-populating a cache the caller just cleared. The architecture reference has the full state machine.

A subtle but important consequence: load() returns a single AdLoadState, but the cache it represents may be larger. A Loaded result with responseInfo only describes the most recent entry; availability().cachedCount is the source of truth for how many ads the cache currently holds. The two views are consistent — the cache never claims more than maxSize and never reports an expired entry — but they answer different questions.

How do I cache more than one interstitial?

Section titled “How do I cache more than one interstitial?”
AdPlacement(
id = "cached_interstitial",
format = AdFormat.Interstitial,
adUnitIds = AdUnitIds(android = "…", ios = "…"),
cachePolicy = AdCachePolicy(
maxSize = 3, // load() tops the cache up to 3 ads
reloadAfterShow = true, // refill in the background after each show
),
)

Each behaviour, in turn. load() fills the cache sequentially and a partial fill still reports Loaded; the cache is full when it has maxSize ads. show() consumes FIFO, oldest first. TTL defaults are one hour for full-screen formats and four hours for app-open, both from AdExpirationPolicy. preload() is an alias of load() — both top up the cache and return the resulting AdLoadState. The caching, retry and timeouts guide goes through every knob.

A warning worth stating plainly: a large maxSize is not free. Every cached ad is a live SDK object with its own TTL, and an ad shown near the end of its hour is more likely to be an expired eviction than an impression. The default of one is right for a natural-break interstitial; a maxSize of three is right for a game’s end-of-level cadence; a maxSize of ten is almost always wrong.

reloadAfterShow = true is the right choice for a placement that should always be ready to show. The library schedules a background reload after each show(), so the next break has warm inventory. The cost is the same as a regular load() — the next impression incurred the network cost of a request — but the timing is guaranteed, and the user never sees a “loading” state because no ad is currently being shown.

show() is not reentrant per controller. Calling it again while a previous show() on the same controller is still on screen returns AdShowResult.NotReady immediately — it does not queue and wait for the first presentation to finish. Await the first show()’s result, which suspends until dismissal, before issuing another on the same controller.

The design reason in one sentence: presentation ownership is a one-shot handle, so a queued second show would either double-present or silently swallow the caller’s request. The library surfaces the not-ready case as an explicit result rather than a thrown exception so a caller that genuinely needs to handle a denied show has a typed branch to write.

A common related question: does this rule mean a controller cannot preload while another presentation is on screen? No. load() runs independently of show(). The only operation gated on the “presenting” flag is show() itself. The cache can fill, drain, refill, and TTL-evict while a presentation is on screen. The not-ready rule is about presentation ownership, not about cache state.

How do I check availability before showing?

Section titled “How do I check availability before showing?”
if (interstitial.isReady()) {
interstitial.show()
}
val availability = interstitial.availability()
println(availability.isReady) // Boolean
println(availability.cachedCount) // Int
println(availability.expiresIn) // Duration?

isReady() is a cheap boolean check while availability() returns the full AdAvailability record. Neither is a guarantee — an ad can expire between the check and the show, which is exactly why show() returns a result instead of throwing. Treat both as advisory, and treat AdShowResult as the source of truth.

expiresIn is a Duration?null when the cache is empty. When the cache has one ad, it is the time until that ad expires. When the cache has multiple, it is the time until the oldest ad expires, because that is the next one show() will consume. The field is useful for telemetry — “how long before we lose warm inventory?” — but not for control flow, because the same ad can expire between reading the value and acting on it.