◉RORK LABJP
●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
Articles/Dev Tools
⬡ Dev Tools/2026-10-14Advanced

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.

AdMob72App Open AdAppStateAndroid54Design6

✦ Premium Article

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.

StepCold startReturn from background
1App comes to the front (signal fires)App comes back to the front (signal fires)
2Ad starts loadingThe already-loaded ad is shown
3Ad 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.

  1. Is an ad loaded?
  2. Is the app in front right now?
  3. 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 load
export const COOLDOWN_MS = 240_000; // no second ad within this time of the last one
 
export 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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-10-07
Japanese Headings Won't Turn Bold on Android: How to Trace and Fix a fontWeight That Does Nothing
A troubleshooting note for Rork apps where headings look bold on iPhone but stay thin on Android. How to tell the symptoms apart, pick weights through fontFamily, and verify the fix.
⬡ Dev Tools2026-09-22
Four Numbers to Record Before R8 Becomes the Default — Android Measurements While We Wait for Expo SDK 58
Expo SDK 58 turns on R8 by default for Android release builds. Here is how I capture four numbers — download size, DEX total, cold start, and build time — so the before and after can be compared with the same ruler.
⬡ Dev Tools2026-09-10
I Added a Dim Screen Button, and iPhones Stayed Dark After the App Was Closed
In expo-brightness, setBrightnessAsync applies only to the current activity on Android, but changes the device brightness itself on iOS. Here is why the cleanup burden falls on one platform only, and a hook that restores brightness through AppState.