●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, check for 57.0.21 or later●RORK — GPT-6.1 Sol (09-29) and Claude Sonnet 5.5 (09-28) are in the model menu●SDK 58 — Still in beta (since Sep 15). What to check on an SDK 57 app before the stable release●Q&A — Jest never finishes on a 2-core CPU. A write-up traces the cause in CI●WIDGET — A write-up traces why an iOS widget goes black only in production builds●NEW — Moving an iOS app from a personal account to an organization: five things to clear before App Transfer●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, check for 57.0.21 or later●RORK — GPT-6.1 Sol (09-29) and Claude Sonnet 5.5 (09-28) are in the model menu●SDK 58 — Still in beta (since Sep 15). What to check on an SDK 57 app before the stable release●Q&A — Jest never finishes on a 2-core CPU. A write-up traces the cause in CI●WIDGET — A write-up traces why an iOS widget goes black only in production builds●NEW — Moving an iOS app from a personal account to an organization: five things to clear before App Transfer
My App Open Ad Only Showed When the App Came Back From Background. The 'Returned to Foreground' Signal Fired Before the Ad Had Loaded
On a cold start, the app open ad in my Android app never showed. The foreground signal came before the load began. Here is how I split the signal from the load and rebuilt it as logic you can test ordering by ordering.
It was the night I wrote out my app's launch sequence, one line at a time. As an indie developer who keeps wallpaper apps running, I had assumed the app open ad in my Android app was working. It showed up every time I switched back to the app, so I had no reason to doubt it.
Then I walked the list from the top and found a gap. On a cold start, the launch after the app had been fully closed, there was no place where anyone decided to show the ad. The only trigger was "the app came back from background." That signal does arrive at launch, but it passes before the ad has even started loading.
So the ad showed on return and never on launch. And because it kept showing on return, nothing looked broken. I fixed it at the end of July.
What I'd like to say first is that I didn't change how I use the ad SDK. I changed how I decide when to show. I stopped writing a handler for each signal, and instead ask the same question every time a fact changes. What follows is my write-up of that.
The signal had already passed before the ad was ready
The broken version worked roughly like this: when the "returned to foreground" signal arrives, check whether the ad is loaded, and show it if so. On Android that signal comes from something like ProcessLifecycleOwner; in React Native it's an AppState change.
On return, that is enough. While the app sat in the background, the ad finished loading long ago. At launch the order is reversed.
Step
Cold start
Return from background
1
App comes to the front (signal fires)
App comes back to the front (signal fires)
2
Ad starts loading
The already-loaded ad is shown
3
Ad finishes loading (no more signals)
-
At launch there is nothing to show when the signal arrives. When loading finishes, the code that decides is no longer listening, so the ad just sits there, loaded and unused. In React Native's AppState it is even simpler: the initial state is already active, so the change event never fires at all.
Stop writing per signal; ask the same question whenever a fact changes
My first idea was to add "when loading finishes, show it if the app is in front." That is dangerous. An ad that finishes loading in the middle of use would appear on top of whatever the person is doing. I was about to build a bug worse than the one I was fixing.
So I redrew the line and split the decision into three facts.
Is an ad loaded?
Is the app in front right now?
Is there a live opportunity to show one?
The third is the key. An opportunity is born in two situations, at launch and on return from background, and it expires within a few seconds. An ad that appears several seconds after launch, over a screen someone has already started using, is just an ambush. If the opportunity expires, we skip that round. That one rule removes the danger above.
Here is the deciding part, cut out so that it depends on neither the ad SDK nor any screen.
// app-open-gate.ts: only the part that decides *when* an app open ad may be shown.// It depends on neither the ad SDK nor any screen, so you can swap the clock and test every ordering.export const OPPORTUNITY_WINDOW_MS = 4_000; // how long an opportunity waits for the ad to loadexport const COOLDOWN_MS = 240_000; // no second ad within this time of the last oneexport class AppOpenGate { private loaded = false; private foreground = true; // we start by assuming the app is in front at launch private opportunityAt: number | null; // when a "may show" opportunity began; null if none private lastShownAt: number | null = null; private readonly show: () => void; private readonly now: () => number; constructor(show: () => void, now: () => number = Date.now) { this.show = show; this.now = now; this.opportunityAt = now(); // launching is itself the first opportunity } /** The ad finished loading. */ onLoaded(): void { this.loaded = true; this.evaluate(); } /** Foreground/background changed. Coming back from background starts a new opportunity. */ onForeground(isForeground: boolean): void { const returned = isForeground && !this.foreground; this.foreground = isForeground; if (returned) this.opportunityAt = this.now(); this.evaluate(); } /** Ads are single-use. After closing, the caller starts the next load. */ onClosed(): void { this.loaded = false; } /** Call from a timer so an opportunity that nothing touched can still expire. */ tick(): void { this.evaluate(); } // Instead of writing one handler per signal, ask the same question here // every time a fact changes: loaded? in front? is there an opportunity? private evaluate(): void { if (this.opportunityAt === null) return; const t = this.now(); if (t - this.opportunityAt > OPPORTUNITY_WINDOW_MS) { this.opportunityAt = null; // missed it; drop it so the ad never appears out of nowhere later return; } if (!this.loaded || !this.foreground) return; // not ready yet; keep waiting until the window closes this.opportunityAt = null; // spent here, whether or not we end up showing if (this.lastShownAt !== null && t - this.lastShownAt < COOLDOWN_MS) return; this.lastShownAt = t; this.loaded = false; this.show(); }}
Three notes on why it is written this way.
First, evaluate() is called no matter which signal arrived. Load completion, foreground changes and the timer all land on the same question. When I had separate branches for launch and return, a hole in one was invisible because the other still worked.
Second, an opportunity is spent the moment it is evaluated, whether or not an ad is shown. If an opportunity skipped for the cooldown were kept alive, the ad would appear out of nowhere the moment the cooldown ended.
Third, the clock is passed in from outside. If you call Date.now directly, reproducing "loading missed the 4-second window" means actually waiting.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You'll be able to tell from a written-out launch sequence whether your app open ad is decided at launch, at return, or both
✦You'll have a small gate that decides whether to show from three facts (loaded, in front, opportunity alive), detached from the ad SDK and testable on your own machine
✦You'll avoid shipping the tempting wrong fix, an ad that pops up in the middle of use, by giving each opportunity an expiry
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The nice thing about this piece is that you can build any launch order you like. I also kept the broken logic inside the tests on purpose.
// app-open-gate.test.ts: run with `node --test app-open-gate.test.ts`import { test } from "node:test";import assert from "node:assert/strict";import { AppOpenGate, COOLDOWN_MS, OPPORTUNITY_WINDOW_MS } from "./app-open-gate.ts";function setup() { let t = 0; let shown = 0; const gate = new AppOpenGate(() => { shown += 1; }, () => t); return { gate, advance: (ms: number) => { t += ms; }, shown: () => shown };}// Reproduction of the old logic: decide only on the "returned to foreground" signalclass NaiveGate { loaded = false; shown = 0; onLoaded() { this.loaded = true; } onReturn() { if (this.loaded) { this.shown += 1; this.loaded = false; } }}test("old logic: the ad loads right after launch, but no return signal ever comes, so it never shows", () => { const naive = new NaiveGate(); naive.onLoaded(); // loaded 1.2 s after launch; the return signal has never fired assert.equal(naive.shown, 0);});test("if loading finishes inside the launch window, it shows exactly once", () => { const { gate, advance, shown } = setup(); advance(1_200); gate.onLoaded(); gate.tick(); assert.equal(shown(), 1);});test("if loading misses the window, it is skipped for this launch and never pops up later", () => { const { gate, advance, shown } = setup(); advance(OPPORTUNITY_WINDOW_MS + 1); gate.onLoaded(); advance(60_000); gate.tick(); assert.equal(shown(), 0);});test("an ad that finishes loading mid-session does not show (there is no return to hang it on)", () => { const { gate, advance, shown } = setup(); advance(OPPORTUNITY_WINDOW_MS + 1); gate.tick(); // the launch opportunity has expired advance(120_000); gate.onLoaded(); assert.equal(shown(), 0);});test("returning from background shows a ready ad, but not inside the cooldown", () => { const { gate, advance, shown } = setup(); advance(1_000); gate.onLoaded(); // first ad, at launch assert.equal(shown(), 1); gate.onClosed(); advance(10_000); gate.onForeground(false); advance(5_000); gate.onLoaded(); // the ad reloaded after closing gate.onForeground(true); // a return inside the cooldown assert.equal(shown(), 1); advance(COOLDOWN_MS); gate.onForeground(false); gate.onForeground(true); // a return after the cooldown assert.equal(shown(), 2);});test("it also shows when loading finishes one second after the return", () => { const { gate, advance, shown } = setup(); advance(OPPORTUNITY_WINDOW_MS + 1); gate.tick(); advance(300_000); gate.onForeground(false); gate.onForeground(true); advance(1_000); gate.onLoaded(); assert.equal(shown(), 1);});test("when launched in the background, it waits until the app comes to the front", () => { const { gate, advance, shown } = setup(); gate.onForeground(false); // launched in the background gate.onLoaded(); advance(30_000); gate.tick(); assert.equal(shown(), 0); gate.onForeground(true); assert.equal(shown(), 1);});
On Node 22.18 or later, node --test app-open-gate.test.ts is all it takes. All 7 passed on my machine. The first test is the broken logic: even when loading finishes right after launch, no return signal ever comes, so the ad is shown 0 times.
The fourth test is the safety net for the wrong fix. An ad that finishes loading mid-session must not show. If I had implemented my first idea, "show when loading finishes and the app is in front," this test would have gone red.
A sketch of the Expo wiring
On the SDK side, keep only a thin bridge. This is a sketch using AppOpenAd from react-native-google-mobile-ads.
// startAppOpenAd.ts: a sketch of the wiring, assuming AppOpenAd from react-native-google-mobile-ads.import { AppState } from "react-native";import { AdEventType, AppOpenAd, TestIds } from "react-native-google-mobile-ads";import { AppOpenGate } from "./app-open-gate";// Always use the test unit while developing. Pass the production ID in through an environment variable.const UNIT_ID = __DEV__ ? TestIds.APP_OPEN : (process.env.EXPO_PUBLIC_APP_OPEN_UNIT_ID ?? "YOUR_APP_OPEN_UNIT_ID");let ad = AppOpenAd.createForAdRequest(UNIT_ID);let needsReload = false; // did the previous load fail?const gate = new AppOpenGate(() => ad.show());function load(): void { ad = AppOpenAd.createForAdRequest(UNIT_ID); ad.addAdEventListener(AdEventType.LOADED, () => gate.onLoaded()); ad.addAdEventListener(AdEventType.CLOSED, () => { gate.onClosed(); load(); // ads are single-use; prepare the next one as soon as this one closes }); ad.addAdEventListener(AdEventType.ERROR, () => { gate.onClosed(); needsReload = true; // don't retry on the spot; waiting for the next return avoids a retry loop on a bad connection }); ad.load();}export function startAppOpenAd(): () => void { gate.onForeground(AppState.currentState === "active"); // if launched in the background, wait until it comes to the front load(); const sub = AppState.addEventListener("change", (state) => { gate.onForeground(state === "active"); if (state === "active" && needsReload) { needsReload = false; load(); } }); const timer = setInterval(() => gate.tick(), 1_000); // lets an untouched opportunity expire return () => { sub.remove(); clearInterval(timer); };}
I should say up front that I have not run this bridge on a device. The API names follow the official documentation, but the only part I verified is the deciding logic above. Please run one launch sequence in your own environment, with the test unit.
When the app is launched in the background. Notifications or scheduled work can start the app without bringing it to the front. The launch opportunity then expires unused, and the real "return" when someone opens the app becomes the new opportunity. The seventh test covers this.
Ads are single-use. You have to write the "load the next one after closing" step yourself. If you reload on the spot after a failure, a device on a bad connection keeps retrying. In the sketch above, a failure waits for the next return.
The 4 seconds is my own line. Move it to match the length of your splash screen and how long loading really takes. As for where the ad may appear, Google's App Open ad guidelines say, in spirit, to show it after the loading screen and not to cover content before people have seen it. Please check the current official wording for the details.
Use a monotonic clock.Date.now jumps if the device clock is changed. In React Native you can pass something like performance.now as the constructor's second argument.
What to do first tomorrow
Write your app's launch sequence in four lines. The quickest way is to add a few lines like these to the app and cold-start it once on a real device.
// launch-trace.ts: log the launch order with elapsed time since launchconst t0 = performance.now();export function trace(label: string): void { console.log(`[launch +${Math.round(performance.now() - t0)}ms] ${label}`);}// Where to call it:// trace("came to front") <- right after you subscribe to AppState, with the current state// trace("ad load started") <- just before ad.load()// trace("ad load finished") <- inside the LOADED listener// trace("ad shown") <- just before show() (add whether it was launch or return)
Check the four lines in the order you actually saw them, and mark where the decision to show lives. If the cold-start column has no mark at all, you may have the same hole I had.
One more habit: record, each time an ad shows, whether it was triggered by launch or by return. If you see return counts piling up while launch stays at zero day after day, that is your signal.
I took comfort in the ads that did show and never counted the launches where they didn't. That regret stays with me now as the habit of writing the order out.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.