# A contract for counting ad impressions the same way on three platforms

> A finished load on iOS, an impression callback on Android, a DOM mount on the web: one name, three meanings. A contract that splits the stages of an ad.

- Canonical: https://jaemyeong.com/en/blog/cross-platform-ad-impression-event-taxonomy/
- Published: 2026.08.06
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #iOS, #Android, #Web, #Advertising, #Analytics

In ad analytics, the event named `ad_impression` often gets recorded at a different moment on each platform. One app sends it when the ad finishes loading on iOS, when the SDK reports an impression on Android, and when the ad element is attached to the DOM on the web. The numbers end up in one table, but each platform counts by a different rule. Depending on the platform, the result is an overcount or an undercount.

Five things get mixed under that one name. They are downloading the ad, attaching it to the view hierarchy, overlapping the viewport, being counted as an impression by the SDK, and receiving a revenue callback. On top of that, adding a manual event to one that is already logged automatically counts the same impression twice.

This post is a contract I wrote after comparing what each callback means in the Google, Firebase, and web documentation. I reopened and checked the documents on October 4, 2026. It does not give SDK versions, measured logs, or run results. Choosing an ad provider is out of scope.

## A finished load and a DOM mount are not impressions

Receiving an ad does not mean the user saw it. After loading, the ad may never be attached to the screen, and in the meantime the screen can close or the slot can switch to another ad. Being ready is not enough to count an impression.

Attaching to the DOM on the web is not enough evidence either. [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) observes whether the ratio of overlap between the target and the root crosses a threshold. The default threshold is 0, so a notification can arrive as soon as the edges touch. Right after `observe()` starts, the first notification can arrive even for a target that is not visible. That notification cannot be treated as the basis an ad network uses for billing. What the documentation describes stops at observing the overlap.

## Six stages an ad goes through

I split the mixed signals into stages, and for each stage I wrote down what is still undecided.

- `requested`: the request has started. The response, the display, and the impression are all undecided.
- `loaded`: a response or creative has arrived. The display and the impression are undecided.
- `rendered`: the ad is attached to a view or the DOM. Whether it is really visible and whether the SDK counted it are undecided.
- `app_visible`: the viewport condition defined by the app is met. Whether the network bills for it is undecided.
- `sdk_impression`: the SDK counted an impression. Whether revenue data comes and how precise it is are undecided.
- `paid_value`: revenue for the impression was observed. The settled amount and its accuracy are undecided.

Platform signals map to these stages as follows.

- `bannerViewDidReceiveAd(_:)` on iOS and `onAdLoaded()` on Android are `loaded`. They are not counted as impressions.
- `bannerViewDidRecordImpression(_:)` on iOS and `onAdImpression()` on Android are `sdk_impression`. The app records them only when there is no automatic collection.
- Mounting the ad element on the web is `rendered`. It is not counted as an impression.
- Meeting the observer rule is `app_visible`. It is used only for in-house ads.
- `paidEventHandler` on iOS and `OnPaidEventListener` on Android are `paid_value`. They do not add to the count.

## What counts as one impression

The SDK callback comes first as evidence of an impression. For in-house ads with no SDK, the visibility condition is written down, and when it is met the event is recorded with `evidence=app_visible`. That group is analyzed separately from the group observed by an SDK. Sharing an event name does not make the two groups directly comparable.

In this contract, a paid callback is treated as revenue data attached to an impression that was already counted. It does not add one more. Currency and precision are kept as they are. Precision can be unknown or estimated, so the value must not be read as a final settlement. When no revenue is provided, the value is not filled with 0 and is not calculated backward from the count. If a value is put into the Firebase `ad_impression` event, the currency has to go with it, and precision is kept in a separate revenue path.

The failure side is narrowed as well. `ad_load_failed` is recorded only when a provider that was actually requested failed to respond. A provider that was skipped because it is not registered or is turned off is not a failure. For the same reason, choosing a provider is not enough to send `ad_impression`. If a request was canceled when the screen closed or unmounted and its callback arrives late, it is not treated as a result of the current slot.

## When events are logged automatically, the app does not send them

With AdMob linked to Firebase and automatic logging turned on, the app must not send the same event by hand. Sending the same GA4 event again inside `onAdImpression()` turns one impression into two. An SDK with no automatic collection records one event per callback, and an in-house ad records one event each time the visibility condition is met. External web content where only the DOM can be observed needs its own evidence rule.

Automatic events can lack fields such as `slot` or `selection_source`. Instead of sending another impression to fill them in, the gap is accepted and the analysis is done per `evidence`. Before paid data that goes to a separate server is converted into a GA4 event, the first check is whether automatic logging is on. The contract also states how the filled fields differ between automatic and manual events.

## Slot, attempt, and ad instance

To remove duplicates, the app first has to define what one unit is. A slot is the unit for as long as the UI lives. An attempt is one request to one provider. An ad instance is the unit for as long as one creative lives.

This diagram shows one slot where a request fails, a second request succeeds, and a refresh produces a second impression.

```text
slot instance
  ├─ attempt 1 → load failed
  └─ attempt 2 → loaded → impression 1
                            └─ refresh → impression 2
```

A single Boolean per session misses the impression that comes from a refresh. Sending every callback, on the other hand, can produce duplicates on retries and remounts. So a failure is kept once per attempt and an impression once per instance. When a refresh brings a new creative, it is a new instance, and only a repeated callback from the same instance is dropped. In-memory identifiers are enough for this. There is no need to send unique IDs to GA4 and create a dimension with too many distinct values.

## The shape of the event inside the app

Inside the app, the event has few fields and only fixed values. The example below is not a standard GA4 schema. It is an internal example.

```json
{
  "name": "ad_impression",
  "provider": "global_network",
  "format": "banner",
  "slot": "bottom",
  "evidence": "sdk_impression",
  "selection_source": "normal"
}
```

For `provider`, `format`, and `slot`, the mapping to each platform's enum is written in code. If a provider is added and a mapping is missing, the build or a test fails. The values given for `evidence` in the text are `sdk_auto`, `sdk_callback`, and `app_visible`. How `sdk_impression` in the example maps to those three values is not defined in this post.

`selection_source` is calculated for the object the event points to. If a forced provider fails and another provider's ad is shown, the source of the failure and the source of the impression can differ. Copying one source along the chain of requests distorts the meaning.

## Checking without depending on ad inventory

Waiting for a real ad makes the check depend on inventory. Injecting callbacks directly in a test removes that dependency. The list below gives the expected values for such a check. It is not a record of a run that passed.

- A successful load alone gives 0 impressions.
- One SDK callback gives 1.
- A repeated callback from the same instance still gives 1.
- After a refresh with a new ad, the total is 2.
- For a provider with automatic collection, manual events are 0.
- A provider skipped before any request gives 0 failures.
- When another provider is shown after a failure, there is 1 failure and 1 impression, and the source is calculated for each.
- A late callback after unmount gives 0.
- The first observer notification while not visible gives 0.
- Two independent slots that are each shown give 2.

Putting the SDK callback first, the units of identity, and the design of the internal enums and source are my reading of the documents. They do not guarantee what a network bills or settles. An in-house visibility rule on the web has to be verified down to the threshold, the dwell time, and the reset when the ad leaves the area, and a single entry at one moment cannot tell how long the ad was visible. `trackVisibility` is limited and experimental, so it is left out of the required dependencies. How to link a revenue callback to the earlier impression, and how to handle the order in which callbacks arrive, are not covered in this post.
