Skip to content

Tracking a UI you don't ship: shown, seen, clicked

Server-driven UIDeep dive9 min readMiniwall team

Part of the series: Server-driven UI: a field guide for app teams

On this page

When a server describes the screen, your app stops knowing what it drew. So measure the screen's life, not the pixels: was it asked for, was it drawn, did anyone have a chance to look at it, did they leave, did they tap buy, did the purchase finish. Those are different events, and most "conversion rate" arguments are really arguments about which two of them got divided.

Here is how we'd think about each, what a Miniwall screen reports (from the docs, nothing more), and where the numbers leak.

Steps in order, with who does each: Your app or Screen.

  1. Your app: Asked to open
  2. Screen: Drawn
  3. Screen: Visible long enough
  4. Screen: Buy tapped
  5. Your app: Payment finished
  6. Screen: Closed
Six moments in the life of a remote screen. Only some are events you can collect, and who sees each differs. Shape only, not data.

Why your app no longer knows

With a screen you built in native code, "it was shown" is a fact you can read off your own source. Draw call, then analytics call, ten lines apart.

With a remote screen the description arrives from a server and a renderer you didn't write turns it into pixels (we cover that split in what server-driven UI is). Your app knows it asked. It may know the result. It does not know the layout, so it can't know whether the button is above the fold on a small phone. We made the broader point in the field guide: a request for a screen is not a screen on a display.

The consequence is a design rule. Put the events where the knowledge is. The renderer knows when it drew something; your app knows when money moved. Don't ask either to report the other's facts.

Shown, seen and clicked are three different numbers

Shown means the renderer put the screen on the display. Seen means a person plausibly had it in view. Clicked means they acted on it. The gaps matter.

A screen can be shown under a keyboard, behind a cookie banner, in a tab nobody is looking at, or scrolled off the bottom of the viewport. It's still "shown". Whether it was "seen" depends on a definition you have to make up: how much of it, for how long. We'd write that down before anyone opens a dashboard, because the definition moves the result and nobody should pick it after seeing the numbers.

Here's what a Miniwall screen reports. The docs list these event types[1]:

EventWhen, per the docs
viewA screen is shown. Each screen of a flow is one
closeThe user closes the screen
purchase_clickA purchase button was tapped
purchaseonPurchase finished without returning false, or reportPurchase was called
purchase_cancel, purchase_erroronPurchase returned false, or threw
holdoutThe user was held out
action, answer, restoreA "Run your code" button, a chosen option, a successful restore

Swipe sideways to see every column.

Notice what's not in the list: there's no "visible for a second" event. view is "shown", and the docs don't promise more than that. If your decision depends on "seen", you'll need your own definition and your own measurement, and we'd say so out loud in the report rather than let "views" quietly stand in for it. We haven't checked at which exact instant the SDK emits view relative to the first paint, so we won't claim one.

Asked and drawn are different, too

"Opened" and "drawn" differ because loading can fail, and a failed load is the most boring way to lose a conversion. The SDK says a screen that fails to load isn't counted as a view[4]. That's good for billing. For analysis it means the failure is invisible in the view count: it's the absence of a number.

So the gap between "my code asked for the screen" and view is yours to measure. In web code, presentPaywall returns a result with an outcome: purchased, closed, skipped and others[7]. A skip comes with a reason: frequency_cap, audience or holdout[7]. Log what you asked for and what outcome came back, and you can tell "nobody was shown this on purpose" from "something broke":

js
// Illustration around the documented result. Your own analytics call, not Miniwall's.
// Assumes UIKit is configured and "upgrade_button" is a published placement.
async function openUpgrade() {
  track("upgrade_asked");                        // your event: the code reached this point
  const result = await UIKit.presentPaywall({
    paywall: { placement: "upgrade_button" },
    onPurchase: ({ packageId }) => startCheckout(packageId), // your checkout
    onError: (err) => track("upgrade_error", { message: String(err).slice(0, 80) }),
  });
  track("upgrade_outcome", { outcome: result.outcome, reason: result.reason ?? null });
}

The one caution: don't copy Miniwall's own events into your analytics tool under the same names. Two systems both counting view is how a funnel ends up with two answers. Name yours for what your app knows (asked, outcome), and let the screen's events come from the screen.

Building the funnel

The honest funnel for a remote screen is short: shown, tapped buy, purchased. The dashboard counts them per visitor, showing visitors, those who tapped buy, those who bought, and a conversion figure[1].

Per visitor matters, because views are per showing. Someone who sees the screen four times is four views and one visitor. Each screen of a flow is a view too[4], so a three-step flow inflates "views" threefold before anyone has done anything. Use visitors for rates, views for volume.

A tiny example, made up to be easy to check by hand: 800 visitors, 200 of whom tap buy, 50 of whom complete a purchase. That's 25% of visitors tapping and 25% of those who tapped buying, 6.25% overall. The step ratios tell you where to look. A low tap rate is about the screen. A low tap-to-purchase rate is mostly about checkout, which isn't the screen's job. That distinction is the reason purchase_click exists separately from purchase.

What it can't do is tell you about the rest. Which metric should decide a test is a different question, covered in why conversion alone misleads and reading experiment results.

Payment is not the screen's fact

Miniwall renders; it doesn't charge[3]. So purchase isn't a payment confirmation. By the docs it's recorded when your onPurchase finishes without returning false[2], and on web, returning nothing counts as true[2]. If your checkout handler forgets a return false on cancel, the funnel reports purchases that didn't happen. Test the cancel path.

The order for a tap is documented: purchase_click is recorded, onPurchase runs, then a purchase, cancel or error is recorded[2].

A hosted checkout that leaves the page breaks that chain, since the visitor lands on your success page and the SDK has not heard of the purchase. For that, call reportPurchase there[3]:

js
// On your success page, after you've confirmed the payment on your side.
UIKit.configure({ apiKey: "pk_..." });
UIKit.reportPurchase({ packageId: "annual" });

It credits the screen where the user last tapped buy, or else the last placement they met in the last 30 days, and it does nothing if analytics are off or there is nothing to credit[3]. If you run a holdout, call it after every purchase so both groups are compared fairly[3].

Say the limit plainly in your reports. Miniwall doesn't know who really paid. It records what your site tells it. A refund, a failed renewal, a trial that never converts: none of those reach it, and prices and amounts are never sent[1]. Revenue per visitor, retention and churn live in your billing system. Join them there.

Where events go missing

Count on small leaks and design the reading around them.

Batching. Events go in batches of up to 50, about two seconds after they happen[1]. A tab closed inside that window is a risk, which is why on web events are also sent when the page is hidden, using sendBeacon[1]. A failed send is dropped silently, per the docs. On mobile a failed batch stays queued and is retried later, and events older than seven days are dropped[1]. The docs don't say that either platform reports how many events went missing.

Rate guard. One API instance answers 429 above 1,200 events a minute for a key, and those events are dropped[5]. The docs describe it as a guard against runaway loops, not a quota. If your counts flatline at a ceiling, look for a loop before you celebrate.

Blockers. Events go to the API host, which a site with a strict CSP must allow in connect-src[6]. Our reading, not something the docs say: any tool that blocks that request makes it a failed send, and failed web sends are dropped silently. We haven't tested specific blockers, so we can't say how many visitors that affects. Treat web numbers as a floor.

Duplicates and order. Events from a phone that went offline can arrive late. We haven't verified how the dashboard handles repeats or events out of order, so don't build a reconciliation that expects exact unit counts between it and your billing. Compare ratios over weeks. If someone asks why the dashboard shows 142 purchases and billing shows 149 (example numbers), the answer is "this was never a ledger".

That's also a reason to be modest about small tests. A few percent of silent loss is a lot against a difference of a few percent.

Measure the screen, not the person

Our opinion: an event stream for a remote screen should be able to answer "what happened to this screen" and not "what did this person do".

The SDK helps by default. It never sends the attributes you pass for audiences, text typed into fields, names, emails, IP-based profiles, advertising identifiers or any user identity[1]. Only the chosen options of a choice question go out, with the field and option IDs[1]. The visitor ID is a random UUID, not tied to a person, kept in localStorage under uikit:vid on web[1].

Two things are on you. One is consent. The docs say that if your cookie banner needs consent for this storage, call configure and the other SDK functions after consent[1], or turn analytics off with analytics: false. Frequency limits and holdouts still work with analytics off, because they're counted on the device. Dashboard analytics just stay empty[1]. Whether you need consent is a legal question for your situation. This post isn't legal advice, and we haven't tried to answer it.

The other is what you add. If you wire your own events beside these, don't attach emails or account IDs to them just because the field is there. Ask what decision the field changes. If nothing, don't collect it.

If you also do the "seen" measurement yourself, keep it small. A sketch, using only browser APIs, that fires once when at least half of an element has been visible for a second (both numbers are examples, pick your own and write them down):

js
// Illustration, not a Miniwall API. Sends one "seen" per element to an endpoint you own.
function trackSeen(el, name) {
  let timer = null, done = false;
  const io = new IntersectionObserver(([entry]) => {
    if (done) return;
    if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
      timer = setTimeout(() => {
        done = true;
        io.disconnect();
        navigator.sendBeacon("/events", JSON.stringify({ name, t: Date.now() }));
      }, 1000);
    } else {
      clearTimeout(timer);
    }
  }, { threshold: [0, 0.5, 1] });
  io.observe(el);
}

We haven't run this in every browser. Test it on the ones you support.

What we'd record, and what we'd skip

Record: asked, outcome (including skip reasons and errors), shown, tapped buy, purchase result, closed. Keep screen ID, version and placement on every event so a change in the numbers can be tied to a published change.

Skip: per-pixel clicks, scroll depth on a screen that fits the display, anything that identifies a person, and any number you can't explain how it gets lost. If a metric needs a paragraph of excuses, it's not ready for a decision.

Sources

Go further