DEV Community

Cover image for Rage Taps, Dead Taps, and the 12-Pixel Rule: Cookieless Mobile UX Measurement on Cloudflare D1
baran
baran

Posted on

Rage Taps, Dead Taps, and the 12-Pixel Rule: Cookieless Mobile UX Measurement on Cloudflare D1

The question that started this was not an analytics question. It was: someone opened the page, scrolled to the phone number, and did not call. Why?

Pageviews cannot answer that. Neither can bounce rate. You need to know whether the number was ever actually on screen, whether the visitor tried to tap something that wasn't tappable, and whether they had to pinch-zoom to read it.

I run the website for a small emergency locksmith service in southern Germany, where roughly half the traffic is mobile and the entire business outcome is a single phone call. So I built the measurement myself: one Cloudflare Pages Function, one D1 table, one inline script. No cookies, no localStorage, no consent banner.

This post is about the part that turned out to be genuinely hard: deciding what a tap is.

The first version counted every scroll as a tap

The naive implementation listens for pointerup and records a tap. Run that on a real phone and your data is garbage, because every scroll gesture ends with a pointerup. Every swipe. Every long-press on an image.

The fix is two thresholds, both measured from pointerdown:

addEventListener('pointerup', function (e) {
  if (e.pointerType !== 'touch') return;
  // Swipes and long holds are not taps - otherwise every scroll counts.
  if (Math.abs(e.clientX - px) > 12 || Math.abs(e.clientY - py) > 12) return;
  if (Date.now() - pt > 600) return;
  // ... it's a real tap
}, { passive: true, capture: true });
Enter fullscreen mode Exit fullscreen mode

12 pixels of movement, 600 milliseconds of duration. Move further or hold longer and it is not a tap, it is a scroll or a press. Those two numbers are the difference between a usable dataset and noise.

capture: true matters too: it fires before any handler that might stopPropagation(), so a tap on a component that swallows events still gets recorded. passive: true keeps it off the scroll critical path.

Coordinates as percentages, not pixels

Tap positions are stored as percentages, not device pixels:

var x = Math.round(e.clientX / innerWidth * 100);
var y = Math.round((e.clientY + scrollY) / dh * 100);
Enter fullscreen mode Exit fullscreen mode

x is a percentage of viewport width, y a percentage of full document height, including scroll offset. That makes taps comparable across a 360px phone and a 430px one, and it means "62% down the page" is meaningful across devices with wildly different viewports. Storing raw pixels would have made the data device-specific and effectively unaggregatable.

Dead taps: the visitor thought it was a button

This is the signal I did not expect to be so useful.

var el = e.target.closest('a,button,input,select,textarea,summary,label,[role="button"]');
if (!el) post('deadtap', { x: x, y: y });
Enter fullscreen mode Exit fullscreen mode

If a real tap lands on nothing interactive, the visitor believed something was clickable and it wasn't. On a page where the goal is a phone call, a cluster of dead taps around a styled phone number that isn't wrapped in a tel: link is a conversion bug you would otherwise never find. It looks fine in every review and every screenshot.

Rage taps: three taps, 900 ms, 40 pixels

hist.push({ x: e.clientX, y: e.clientY + scrollY, t: Date.now() });
if (hist.length > 3) hist.shift();
if (hist.length === 3 && hist[2].t - hist[0].t < 900
  && Math.abs(hist[2].x - hist[0].x) < 40 && Math.abs(hist[2].y - hist[0].y) < 40) {
  post('rage', { x: x, y: y }); hist = [];
}
Enter fullscreen mode Exit fullscreen mode

A three-element sliding window. Three taps inside 900 ms, all within 40 pixels of each other, means the visitor is hammering the same spot. Something is unresponsive, or slow, or looks interactive and isn't.

The window resets after firing, so one frustrated burst produces one event rather than a cascade.

Pinch-zoom tells you where the text is too small

Most implementations record that a zoom happened. That is close to useless on its own. The useful part is the position:

if (vv.scale > 1.15 && !zoomed) {
  zoomed = 1;
  post('zoom', { sc: Math.round(vv.scale * 10) / 10,
                 y: Math.round((vv.offsetTop + scrollY) / dh * 100) });
}
Enter fullscreen mode Exit fullscreen mode

visualViewport gives both the scale and the offset. Recording the vertical offset as a percentage of the document turns "someone zoomed" into "someone zoomed at 78% down the page" — which is an actual, actionable pointer at a specific block of text. The 1.15 threshold filters out incidental scale jitter, and the !zoomed guard means one event per page view, not one per pinch frame.

"Did they even see the number?"

The most important signal separates two very different failures: no interest versus never found it.

var io = new IntersectionObserver(function (es) {
  for (var i = 0; i < es.length; i++) {
    if (es[i].isIntersecting) {
      if (!sawPhone && !seenT) seenT = setTimeout(function () { sawPhone = 1; }, 1500);
    } else if (seenT) { clearTimeout(seenT); seenT = 0; }
  }
}, { threshold: 0.6 });
document.querySelectorAll('a[href^="tel:"]').forEach(function (el) { io.observe(el); });
Enter fullscreen mode Exit fullscreen mode

Two deliberate choices. threshold: 0.6 means 60% of the element must be visible — a sliver scrolling past the edge does not count. And the 1500 ms timer, cancelled if the element leaves the viewport, means the number must stay on screen. Scrolling straight past it at speed does not set the flag.

That gives a clean query: sessions where the number was seen and no call event followed. Those are the ones worth investigating.

One beacon at the end, and why not unload

Everything above accumulates in memory and ships once:

document.addEventListener('visibilitychange', function () {
  if (document.visibilityState === 'hidden') exit();
});
Enter fullscreen mode Exit fullscreen mode

unload and beforeunload are unreliable on mobile — iOS in particular often kills a backgrounded tab without firing them. visibilitychange on the document (not window) fires when the user switches apps, which on a phone is the normal way a session ends.

Because it can fire more than once, exit() carries a 1-second debounce and always sends the cumulative state, so a duplicate is harmless rather than a partial overwrite. The payload is small: duration in seconds, max scroll depth, whether a zoom happened, whether the number was seen, tap count, and the tap list itself (capped at 25, mobile only).

Privacy as a constraint, not a checkbox

German law (TTDSG §25, on top of GDPR) makes any read or write to device storage consent-gated. A consent banner would have wrecked the measurement — people decline, and you are back to partial data.

So the system touches no storage at all. No cookies, no localStorage, no sessionStorage. Same-day device grouping comes from a non-reversible hash with a daily rotating salt, which buys grouping within one day and nothing more. Cross-day identity is gone; that is an accepted trade, not an oversight.

Two other rules fell out of that: desktop collects none of the touch signals (they would be noise, and desktop users read the number instead of tapping it), and text selection records only a boolean flag for "this looked like a digit sequence" — never the selected text itself.

What it actually answers

A single D1 query now answers the question I started with — the sessions where the number was on screen, stayed there, and no call or copy event ever followed:

SELECT page, COUNT(*) n FROM events
WHERE ev = 'exit' AND bot = 0 AND dev = 'm'
  AND json_extract(meta, '$.p') = 1
  AND vid NOT IN (SELECT vid FROM events WHERE ev IN ('tel_click','copy'))
GROUP BY page ORDER BY n DESC;
Enter fullscreen mode Exit fullscreen mode

That is the list of pages where people found what they came for and left anyway. Combined with the dead-tap and zoom coordinates on those same pages, you usually get the reason within a minute.

The takeaway

The interesting engineering here was not the storage or the endpoint. It was the thresholds: 12 pixels, 600 milliseconds, 3 taps in 900 ms, 60% visible for 1500 ms, scale above 1.15.

Every one of those exists because the naive version produced confident, wrong data. A tap detector without the movement threshold reports that every visitor taps constantly. A "did they see it" flag without the dwell timer reports that everyone saw everything.

If you build this yourself, budget your time for the thresholds, not the plumbing.

Top comments (0)