DEV Community

Scroll depth couldn't tell my users from my bounces

Here's a thinking process:

  1. Analyze User Input:
    • Role: Expert editor for DevPlace (social network for software developers)
    • Task: Reformat raw article body into clean, readable Markdown for technical audience
    • Rules:
    • Break into short, well-structured paragraphs separated by blank lines
    • Add Markdown section headings (## Heading) where topic clearly shifts
    • Use bullet/numbered lists for enumerations, inline code/fenced code blocks for code/commands/identifiers
    • Preserve every fact, name, number, quotation exactly as given. Never invent, add, remove, or reorder information.
    • Only restructure and lightly polish wording for flow and grammar; no introduction, conclusion, opinion, or commentary
    • Do not repeat article title as heading, don't wrap whole answer in code fence
    • Output only reformatted article body as Markdown, nothing else
  • Input Text: A long article titled "Scroll depth couldn't tell my users from my bounces" with mixed prose, code snippets, tables, and data points.
  • Constraints: Must preserve all facts, numbers, names, quotations exactly. Must restructure lightly for flow/grammar. Must use Markdown formatting as specified.
  1. Deconstruct the Input Text:
    Let me parse the article structure mentally:

    • Title: "Scroll depth couldn't tell my users from my bounces"
    • Article body starts with: "I have a browser photo editor that runs entirely client-side..."
    • Contains sections/proxies, why proxy can't work, three events/fix, two details worth stealing, what the data said, one GA4 trap that cost me, caveats, actual lesson
    • Has code blocks (TypeScript)
    • Has a table (Markdown table likely needed)
    • Has bullet points/numbered lists implicitly
    • Has specific numbers, percentages, names
  2. Plan the Reformatting:
    I need to go through the text and:

    • Identify topic shifts for ## headings
    • Keep all content verbatim (no changes to facts, numbers, names, quotes)
    • Format code blocks properly (fenced with ``` or inline code for identifiers)
    • Use bullet lists for enumerations
    • Format the table as Markdown table
    • Ensure paragraphs are short and separated by blank lines
    • No title as heading, no wrapping code fence, just the body

Let me outline the sections based on natural breaks:

  • "Why the proxy can't work on this page" (starts with "Why the proxy can't work...")
  • "Three events The fix was not clever." (starts with "Three events")
  • "Two details worth stealing" (starts with "Two details worth stealing")
  • "What the data said" (starts with "What the data said")
  • "One GA4 trap that cost me" (starts with "One GA4 trap that cost me")
  • "Caveats, because 48 hours is 48 hours" (starts with "Caveats")
  • "The actual lesson" (starts with "The actual lesson")

Wait, I need to be careful. The user said "Add Markdown section headings (## Heading) where the topic clearly shifts, so the article scans well." I should identify natural topic shifts. Let me re-read and pinpoint exact starts of major sections.

Actually, looking at the text, it's already somewhat segmented. I'll structure it logically while preserving order. The text flows linearly, but I can add headings where topics shift clearly.

Let me trace the shifts:

  1. Opening narrative about GA4 and Clarity
  2. "Why the proxy can't work on this page" - clear shift
  3. "Three events The fix was not clever." - shift to code/solution
  4. "Two details worth stealing" - shift to principles
  5. "What the data said" - shift to results
  6. "One GA4 trap that cost me" - shift to technical trap
  7. "Caveats, because 48 hours is 48 hours" - shift to limitations
  8. "The actual lesson" - concluding takeaway

But I must preserve order and not add/remove content. I'll insert ## headings at these points, but I need to make sure the heading text exactly matches or appropriately represents the shift without inventing. The rule says "Preserve every fact, name, number, and quotation exactly as given. Never invent, add, remove, or reorder information." and "Only restructure and lightly polish wording for flow and grammar". Headings are not facts/numbers/quotations, they're structural. I think it's okay to add headings as per rule: "Add Markdown section headings (## Heading) where the topic clearly shifts". I need to choose heading titles that reflect the shifts. I should be careful not to change the content's meaning.

Let me look at the exact text to find where each section starts naturally. I'll copy the text and mentally mark.

Actually, I'll just go through the text paragraph by paragraph in my mind, but since I'm an AI, I'll simulate the reformatting. I need to output Markdown. I'll make sure:

  • Code blocks are fenced properly
  • Inline code for identifiers like gtag, track, Params, etc.
  • Bullet lists for enumerations (e.g., the five GA4 events, the two details worth stealing, etc.)
  • The table is converted to Markdown table format
  • All numbers, percentages, names stay exactly
  • No new information added

Let me outline the content flow:

Paragraph 1: "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."

Shift: Why the proxy can't work...

Paragraph 2: "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."

Wait, the heading "Why the proxy can't work on this page" appears in the text. I should make it a heading. Let me re-read carefully. The text says: "Why the proxy can't work on this page The editor sits about 7%..." So that's a subheading inline. I'll make it ## Why the proxy can't work on this page.

Continue:
Shift: "Three events The fix was not clever."

Paragraph: "Three events The fix was not clever. Three events and an afternoon." Then code block.

Actually, "Three events The fix was not clever." seems like a heading too. I'll structure accordingly.

Let me re-read the whole thing carefully and identify all segments. I'll do it systematically.

The text:
"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 }) } 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 } } 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. 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. 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."

I notice there's a duplicate "One GA4 trap that cost me" section. In the text it appears twice? Let me check: After "On export: JPEG was 85 of 86 download events..." then "One GA4 trap that cost me Custom dimensions don't backfill..." then later again "One GA4 trap that cost me Custom dimensions don't backfill..." Yes, it's duplicated in the input. I need to preserve exactly as given, so I'll include it as is, but since I'm reformatting, I'll just output what's there. The user said "Preserve every fact, name, number, and quotation exactly as given. Never invent, add, remove, or reorder information." So I must keep the duplication if it's in the original. But maybe it's a typo in the prompt. I'll preserve exactly.

Now, I need to restructure into Markdown. I'll identify natural heading placements. The text has inline headings like "Why the proxy can't work on this page", "Three events The fix was not clever.", "Two details worth stealing", "What the data said", "One GA4 trap that cost me", "Caveats, because 48 hours is 48 hours", "The actual lesson". I'll convert these to ## headings.

I need to be careful: the headings should not repeat the article title. The title is "Scroll depth couldn't tell my users from my bounces". I won't use that as a heading. I'll start with the body.

Let me plan the Markdown structure:

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.

```typescript
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 })
}

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
}
}
Set the flag in the branch where the thing actually happened, not from the intent.

  1. 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. 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.

Wait, the duplicate. I'll just include it once as it appears, but the text has it twice. I need to check the exact input. Looking at the input: after "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...." then later "One GA4 trap that cost me Custom dimensions don't backfill...." again. It's repeated. I'll preserve exactly. Since the rule says never remove or reorder, I'll include both occurrences as they are in the text. But in Markdown, I'll just flow with it. Maybe I should just output the text as-is but formatted, keeping the duplicate. However, the user might expect me to notice and possibly deduplicate? The rule says "Never invent, add, remove, or reorder information." Removing would be removing information. So I must keep the duplicate. But it's likely a copy-paste artifact in the prompt. I'll handle it by keeping the exact text, which means the "One GA4 trap..." section appears twice. I'll just format it faithfully.

Let me re-scan the input for the exact duplicate placement. In the user's message, after "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 retro

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.