HarperFast / HarperFast/studio

[RUM] /sign-in blocks the main thread 8.5× harder than the rest of Studio — eager marketing tags, p95 1,136ms across 335 sessions

Open
#1,684 0 comments 0 reactions 0 assignees View on GitHub
rum
Dominant language
TypeScript
Stars
5
Forks
4
Avg merge
1d 8h
Merged PRs (30d)
40

Description

`/sign-in` is Studio's main-thread hotspot, and almost all of the blocking is third-party
marketing tags that `src/App.tsx` loads eagerly above the router. This is a **standing
condition, not a spike** — which is why the usual adjacent-window comparison never surfaced
it. p95 and p99 are flat across 14d and 30d.

## The measurement

`@long_task.blocking_duration` p95 (LoAF blocking time — the actual main-thread cost, not
frame `duration`), production RUM app `f590deee`, 30d to 2026-09-07:

| Scope | frames | p95 blocking |
|---|---|---|
| `@view.url_hash:*sign-in*` | 5,572 | **1,136ms** |
| everything else | 22,964 | **134ms** |

An 8.5× gap. Stable over time — sign-in p95 was 1,182ms on 14d, 1,136ms on 30d.

Sign-in is one screen out of ~40, and it accounts for **19.5% of all long animation frames**
in the application (5,572 of 28,536).

Reach, 30d:

- **335 sessions** emitted at least one long animation frame on sign-in, out of 1,322 sessions
that viewed sign-in at all (**25%**).
- **314 frames blocked the main thread for over 1 second**, across **84 sessions** (6.4% of
sign-in sessions).
- Worst single frame: 5,445ms duration / **5,392ms blocking**.

That last ratio matters. `blocking_duration ≈ duration` on the worst frames, so this is real
main-thread work — not the rAF-starvation pattern (`render_start - start_time ≈ duration`
with an empty `scripts` array) that made a previous long-task investigation a false positive.
`@long_task.entry_type` is `long-animation-frame` for 28,303 of 28,536 frames over the same
30d, so the collection mode has not changed either.

## Attribution

Taking only frames where LoAF attributed **exactly one** distinct script host — so the
attribution is unambiguous — over 14d of sign-in frames:

| Attributed host | frames | total blocking | avg/frame |
|---|---|---|---|
| `static.reo.dev` (Reo.dev intent pixel) | 90 | 26,619ms | 296ms |
| `www.googletagmanager.com` | 41 | 12,295ms | 300ms |
| `fabric.harper.fast` (our own bundle) | 44 | 9,702ms | 220ms |
| `connect.facebook.net` | 21 | 4,949ms | 236ms |
| `js.stripe.com` | 11 | 2,796ms | 254ms |
| `www.redditstatic.com` | 6 | 430ms | 72ms |
| `capi-automation.s3.us-east-2.amazonaws.com` | 4 | 328ms | 82ms |
| `snap.licdn.com` (LinkedIn Insight) | 3 | 173ms | 58ms |
| `js-na2.hs-analytics.net` (HubSpot) | 9 | 115ms | 13ms |

Third-party tags are ~48s of the ~58s of unambiguously-attributed blocking. Our own bundle is
9.7s of it — worth its own look, but it is not the bulk.

The most common individual invokers are `TimerHandler:setTimeout | static.reo.dev` (118),
`static.reo.dev` bare (43), `SCRIPT[src="static.reo.dev…"]` (29),
`IdleRequestCallback | static.reo.dev` (26), `Permissions.query.then | static.reo.dev` (17),
and `TimerHandler:setTimeout | www.googletagmanager.com` (39).

## Mechanism

[`src/App.tsx`](../blob/stage/src/App.tsx) calls all three integrations unconditionally, above
the router, on first render:

```tsx
export function App() {
useReo();
useDatadog();
useGTM();
```

- `useReo` (`src/integrations/reo/reo.ts`) injects `static.reo.dev` from a `useEffect` with no
deferral.
- `useGTM` (`src/integrations/google/gtm.ts`) injects container `GTM-5QQX432`, which in turn
loads Facebook, LinkedIn, HubSpot (analytics + collected-forms + banner), Reddit, Apollo,
and `aplo-evnt.com`. None of those hosts appear anywhere in this repo — they arrive through
the container, so the blast radius of the GTM tag is whatever marketing has configured.

Because these are above the router, they load on **every** entry point. Sign-in absorbs the
whole cost because sign-in is where an unauthenticated visitor's *initial load* happens — all
seven-plus tag bundles boot concurrently with app hydration. Once past the auth wall, route
changes are cheap because the scripts are already parsed, which is exactly the 134ms figure
in the table above.

Note also #1620: ~90% of Reo's own event POSTs have been rejected `400` for 30+ days. So the
single largest blocking contributor on the sign-in critical path is currently paying full
main-thread cost for telemetry that mostly does not land.

## Honest caveats

- **INP is not degraded.** `@view.interaction_to_next_paint` p75 is 48ms on sign-in vs 40ms
elsewhere (n=378 / n=4,189, 30d). This is a *load-phase* responsiveness cost, not an
interaction-latency one, and it will not show up in an INP dashboard.
- **We cannot measure the load-time user impact directly**, because Core Web Vitals collection
is dead (#1570) — LCP/FCP are absent from initial loads, so the obvious "does this delay
first paint on the sign-in funnel" query has no data behind it. Fixing #1570 would let us
quantify the win rather than infer it.
- These are p95/p99 tails on a low-traffic app (2,546 sessions in 30d). The tail is where a
slow device or a slow network lands, which is also where a sign-in abandonment would come
from — but nobody has tied a specific abandonment to it.

## Suggested direction

Ordered cheapest-first; none of these removes a tag:

1. **Get the tags off the initial-load critical path.** Defer `useReo()` and `useGTM()` behind
`requestIdleCallback` (or a `setTimeout` after first paint) instead of firing them in a
mount effect. Preserves every tag and every pageview; only the timing moves.
2. **Reconsider loading Reo on the auth screens at all**, given #1620. It is the largest single
contributor and its payloads are being rejected.
3. Separately, look at the 9.7s attributed to our own bundle on sign-in — smaller, but it is
ours to fix outright.

(1) is a measurement-vs-performance tradeoff that marketing owns, not purely an engineering
call — deferring a tag can shift attribution for the acquisition funnel — so this is filed for
that conversation rather than fixed unilaterally.

## Reproducing the numbers

```
APP='@application.id:f590deee-4bac-49b4-a202-3b6963d9721d'
pup rum aggregate --query "$APP @type:long_task @view.url_hash:*sign-in*" \
--compute 'percentile(@long_task.blocking_duration, 95)' --from 30d --to now
pup rum aggregate --query "$APP @type:long_task -@view.url_hash:*sign-in*" \
--compute 'percentile(@long_task.blocking_duration, 95)' --from 30d --to now
pup rum events --query "$APP @type:long_task @view.url_hash:*sign-in*" \
--from 14d --to now --limit 600 # then group scripts[].source_url by host
```

Values come back in nanoseconds. Found by the daily RUM review, 2026-09-07.

Contributor guide

Open the contributing guide

Research direction

Start with src/App.tsx and the integration entry points src/integrations/reo/reo.ts and src/integrations/google/gtm.ts to understand how the tags load before routing. Run the listed pup rum aggregate and events queries to reproduce the measurements. Done means an agreed approach for the initial-load cost is implemented and its effect can be checked against RUM data.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
frontend, performance, web-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.