Catching a Stuck Interstitial Ad on Android With Event Logs
By Nesim Razon, independent Android developer · 2026-11-12
A player wrote that after an interstitial ad the game stopped responding to touches. There was no crash in Crashlytics, no ANR, and no way to reproduce it on our own phones. This post describes how we added a small lifecycle log around AdMob full-screen ads, what the data looks like, and what we did with it. It is a debugging story, not a recipe that fits every app.
The report that had no stack trace
The report came from one user on a Redmi phone running HyperOS and Android 13. At the end of a level a full-screen ad appeared. When it was over, the game looked normal but ignored the player. Touches seemed to land somewhere else: the Play Store panel opened by itself, and the button for the next level did nothing. The player said it had happened once before with the same kind of ad.
From the outside this looks like a freeze, but the app was not frozen. The main thread was fine and nothing threw an exception. That is exactly why the usual tools stayed quiet.
Why crash reporting cannot see this
Crashlytics records fatal exceptions and, if you ask, non-fatal ones. A stuck ad is neither. The ad SDK runs its own activity and a web view, and if the creative misbehaves, the window may simply stay on top of your game as a transparent layer. Your code never fails. The user just cannot reach it.
ANR reports do not help either, because the app keeps answering input events. The only party that knows something went wrong is the ad SDK, and it did not tell us. So the first job was to make the invisible part visible: write down what the ad did, in order, on real devices.
The idea: log every stage of the ad
AdMob gives you callbacks for load and for full-screen content. We attached a tiny logger to each of them, on both interstitial and rewarded formats. Every call produces one Firebase Analytics event named ad_event with these parameters: format (interstitial or rewarded), stage, adapter, code and ms.
The stages in our code are loaded, load_failed, shown, impression, clicked, dismissed, show_failed and one more that we invented, resumed_no_dismiss. The adapter is the class name of the mediation adapter that served the ad, shortened to its last 60 characters, so one event shows which ad source was involved. The code field carries the SDK error code for failures, and ms is the time since the ad was shown. The same line is also written as a breadcrumb into Crashlytics, so if a crash does happen later, its report shows which ad stage came before it.
The one stage the SDK does not give you
Every normal ad ends with a dismissed callback. A stuck ad is the case where that callback never arrives, so you cannot log it from a callback. You have to notice the absence.
We did it with the app lifecycle. When an ad is shown, the wrapper stores its format, adapter and start time. A global activity lifecycle listener then watches onActivityResumed. If one of our own activities resumes while the wrapper still believes an ad is on screen, the ad should have closed but did not tell us. We ignore activities from the ad SDK itself, require at least 1.5 seconds since the ad was shown to avoid races, and report only once per ad. The result is a resumed_no_dismiss event with the elapsed time. The listener only records. It never touches the game, so it cannot make anything worse.
Turning events into a table
Raw events are not a diagnosis. We pull the numbers from the GA4 Data API into a weekly report. For this to work, format and stage must be registered as event-scoped custom dimensions in GA4, otherwise the query returns an error. The report groups by format and stage and shows two numbers for each cell: how many events and how many users.
Read down one column and you see the life of an ad: loaded, shown, impression, dismissed. The healthy shape is that dismissed is close to shown. The report adds one extra line: stuck events divided by shown events, as a percentage. The dashboard warns when the count of resumed_no_dismiss is above zero, and it points to AdMob's ad review tools. The pair to look for is shown without dismissed, which the resumed_no_dismiss event makes explicit.
What we found and what we changed
The user's description pointed at one specific third-party game ad that sometimes left a transparent layer on screen. Because the ad came through the normal AdMob path, the fix was on the demand side, not in our code: we blocked that advertiser's domain in the AdMob blocking controls. We did not change how the game shows ads in the same release, on purpose. First we measured, so that a later fix could be compared with a baseline.
We should be honest about the evidence. The first signal was one user report, plus the knowledge that the same creative had caused trouble before. The logging was added to learn how often the problem occurs and which adapter is behind it, and the block was a targeted response to the creative the user described. I do not have a controlled before-and-after experiment, and with a small player base the counts are small. Treat our numbers as a smoke alarm, not as a measurement with a confidence interval.
Limits and privacy
The heuristic can produce false positives. A device that resumes our activity for another reason while the wrapper still thinks an ad is up could be counted, which is why the threshold and the once-per-ad rule exist. It can also miss a stuck ad if the user force-closes the app. And Analytics events are sampled and batched by the SDK, so the numbers lag a day or two.
On privacy: the event carries only the format, the stage, a shortened adapter class name, an error code and a duration. There is no user identifier in our parameters, and the code comment states that the advertising ID is not collected. Analytics is switched on only in the release build, so tests do not pollute the numbers. We also use the usual consent flow for ads through Google's User Messaging Platform. If your app collects analytics in regions with consent rules, check how your own setup gates Firebase Analytics, because we have not verified a particular consent-mode configuration here.
What to take from this
If you ship ads, you probably have a blind spot between show and dismiss. A few dozen lines can close it: log each callback, derive the missing one from the activity lifecycle, and chart format by stage. Keep the logger passive. Report what happened, do not try to fix the screen automatically until you know what you are fixing.
A stuck ad costs you in two ways. The player leaves a bad review, and you lose the session. Both are hard to attribute without data. With a lifecycle table, the next report from a user is no longer a story you cannot check, but one row you can look up.
Frequently asked questions
Why does Crashlytics not show a stuck interstitial ad?
Nothing crashes. The ad window stays on top of the app while the app itself keeps running, so there is no exception and no ANR to record.
How do you detect an ad that never closes?
Store the time the ad was shown, then watch the activity lifecycle. If one of your own activities resumes while the ad is still marked as showing, report an event such as resumed_no_dismiss.
Which parameters are logged with each ad event?
The ad format, the stage, a shortened adapter class name, an error code for failures and the milliseconds since the ad was shown.
Do I need to register the parameters in GA4?
Yes. To query format and stage through the GA4 Data API, register them as event-scoped custom dimensions.
How was the stuck ad fixed?
By blocking the advertiser's domain in AdMob. The logging itself did not change behavior; it exists to measure how often the problem happens.
More from the blog
- How a Daily Puzzle Can Be the Same for Everyone, No Server
- How Hundreds of Puzzle Levels Are Generated and Checked
- Keeping Two Dozen Android Apps Maintainable as a Solo Dev
How We Guarantee a Mahjong Solitaire Board Is Solvable
Mahjong Solitaire History and How It Differs From Mahjong- Tic-Tac-Toe: A Solved Game, an Early Computer and Its Variants