DEV Community

hb lai
hb lai

Posted on

Scroll depth couldn't tell my users from my bounces

I have a browser photo editor that runs entirely client-side. For months, the only analytics my GA4 property had recorded were five events it collects without being asked: page_view, session_start, user_engagement, first_visit, and scroll (that last one via Enhanced Measurement).

Not one of them tells you whether anybody used the tool.

So I did what you do when you have no product data: I reasoned from proxies. Microsoft Clarity told me 77% of homepage sessions never passed 25% scroll depth, and that sessions longer than 30 seconds still averaged 24.7%. I read that as a problem — people arrive, don't engage, leave — and spent an embarrassing amount of time optimising against it.

Then I measured the thing itself, and the proxy turned out to be useless here. Not wrong in the sense of pointing the other way. Useless in the sense that it cannot distinguish a satisfied user from a bounce.

Why the proxy can't work on this page

The editor sits about 7% down the homepage — 595px on the phone viewport I tested, inside the initial screen.

So the complete success path is: arrive, drop in a photo, tap a preset, hit download, leave. Zero scrolling required. A user who does exactly what the site exists for produces the same scroll-depth reading as someone who took one look and closed the tab.

That's the actual problem. Not that the metric was lying — that it had no way to separate the two populations, and I'd been reading a number that was structurally incapable of answering my question.

Three events

The fix was not clever. Three events and an afternoon.

type Params = Record<string, string | number | boolean>

declare global {
  interface Window {
    gtag?: (command: string, event: string, params?: Params) => void
  }
}

function track(event: string, params: Params = {}) {
  if (typeof window === 'undefined' || typeof window.gtag !== 'function') return
  try {
    window.gtag('event', event, params)
  } catch {
    // Analytics must never break the editor.
  }
}

export function trackPhotoLoaded(source: 'upload' | 'sample' | 'paste', variant: string) {
  track('photo_loaded', { source, page_variant: variant })
}
Enter fullscreen mode Exit fullscreen mode

Plus preset_selected and photo_downloaded.

Everything is categorical on purpose. No filename, no dimensions, no EXIF, nothing derived from the image. The site's pitch is that your photo never leaves your device, and the telemetry has to be consistent with that claim or the claim is just marketing.

Two details worth stealing

1. Report what happened, not what was configured.

The download handler originally derived the logged flags straight from the export settings — resized: needsResize. That's a lie waiting to happen. If getContext('2d') returns null, the composite canvas is created but never becomes the exported image, while the settings still say a resize was wanted. The event would report a transformation that never ran.

let finalCanvas: HTMLCanvasElement = canvas
let appliedResize = false
let appliedStamp = false

if (needsResize || stampActive) {
  const ctx = composite.getContext('2d')
  if (ctx) {
    // ...draw, then...
    finalCanvas = composite
    // Only true once the composite is the canvas actually being exported.
    appliedResize = needsResize
    appliedStamp = stampActive
  }
}
Enter fullscreen mode Exit fullscreen mode

Set the flag in the branch where the thing actually happened, not from the intent.

2. Make provenance a required argument.

While building this, source briefly had a default of 'upload'. That quietly defeats the whole point of the field — every unlabelled path silently claims to be an upload. Deleting the default turned it into a compile error, and TypeScript immediately named the two call sites that had been relying on it (the drag-drop handler and the file-picker onChange). A default on a provenance field is a bug that reports itself as clean data.

What the data said

First 48 hours. These rows are user counts, from GA4's totalUsers:

Step Users Of visitors
Visited 465
Loaded a photo 85 18.3%
Manually selected a preset 80 17.2%
Initiated a download 29 6.2%

34% of the users who loaded a photo went on to click download. (The event fires immediately after link.click(), so it records an initiated download — not proof the browser finished writing the file.)

And the comparison that made the point:

  • Users who triggered GA4's 90%-scroll event: 17
  • Users who loaded a photo: 85

Five times as many users loaded a photo as ever reached 90% scroll depth.

That doesn't prove successful users scroll less — I can't show that from aggregate counts. It shows scroll depth was never going to find these 85 people, which is all I needed to know to stop steering by it.

Two more things, these ones event counts, not users:

  • 152 of 159 recorded loads came through the upload path, versus 7 sample clicks and 0 pastes. People are arriving with a file in hand rather than poking at the demo images.
  • 1,258 preset_selected events across 80 users — about 15.7 recorded selections each. The handler fires on every click, including re-clicking the same preset, so that's selections, not distinct looks tried. The site has 15 presets and each was selected by somewhere between 32 and 69 users. My working hypothesis is that people flip through the whole set rather than arriving knowing which one they want — which would argue for a different UI than a grid of named filters. That's a hypothesis, not a finding; 48 hours can't settle it.

On export: JPEG was 85 of 86 download events (PNG: one), and 14 of 86 — one in six — had the retro date stamp switched on, far more than I expected for a feature I nearly cut.

One GA4 trap that cost me

Custom dimensions don't backfill. Event parameters stay invisible in GA4 reports, explorations, and the Data API until you register them under Admin → Data display → Custom definitions — and registration only applies going forward. Data collected before you register is not retroactively queryable by that parameter there. (If you have BigQuery export running, the raw parameters are still in the export; this is a reporting-layer limitation.) Newly registered dimensions can also take a while to show up. Register them the same day you ship the events.

One naming note: I called a dimension source, which is also the name of a built-in GA4 traffic dimension. My reports came back with my upload / sample values and traffic sources like google in the same result set. I haven't pinned down whether that was the shared name or my own query, so I won't claim a mechanism — but photo_source would have cost nothing and removed the ambiguity. Pick a name nothing else owns.

Caveats, because 48 hours is 48 hours

Small n, two days, one tool, one traffic mix. GA4's scroll fires at ~90% depth, so "17 users" means 17 reached near the bottom — not that only 17 people scrolled at all. The Clarity 25% figure is a different tool over a different window; I'm comparing the shape of two signals, not computing a ratio between them.

The actual lesson

It isn't "scroll depth is bad." It's that a proxy metric carries the assumptions of the product it was designed for. Scroll depth assumes the value is distributed down the page. Put the value in the first screen and the metric doesn't break loudly — it keeps returning a plausible number every day, and quietly answers a question you weren't asking.

If you ship a tool and your analytics can't answer "did anyone use it," that's the only instrumentation task that matters. It took an afternoon. I should have done it a year ago.

The tool being measured is DigicamFilter, if you want to see what the numbers are about — free, and the photo genuinely never leaves your browser, which is also why the telemetry is as boring as it is.

Top comments (0)