Skip to content

fix(auth): share the in-flight me() request - #245

Merged
guyofeck merged 2 commits into
mainfrom
fix/single-flight-me-analytics-session
Aug 16, 2026
Merged

fix(auth): share the in-flight me() request#245
guyofeck merged 2 commits into
mainfrom
fix/single-flight-me-analytics-session

Conversation

@guyofeck

@guyofeck guyofeck commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

BUG-837: an app is ~190ms slower on every cold load because the SDK issues a second GET /entities/User/me at client construction while the app is already issuing its own. Chrome serializes the two identical GETs, so the second pays the first's full latency.

The source is the analytics module: createAnalyticsModule runs at client construction, trackInitializationEvent queues an event, startAnalyticsProcessor sends the first batch before the throttleTime sleep, and flush() awaits getSessionContext()auth.me().

Fix

Share the in-flight request in auth.me(). Both the app and analytics call this same method, so collapsing it there is what removes the duplicate — deduping inside the analytics module would not help, and getSessionContext already shares its own promise.

The pending promise is cleared as soon as the request settles, so no resolved user is ever retained. Caching identity across requests would leave an app rendering a logged-in view after logout or a session swap. setToken and logout also drop the pending request: a me() already in flight must not resolve into callers that run after the identity changed.

This also collapses apps that call me() twice on their own.

Two related fixes in the analytics module

  • Stale user_id. The session context memoized the resolved user_id for the lifetime of the session and nothing ever reset it — not logout, not setToken. A visitor who loaded a page anonymously and then logged in kept reporting user_id: null on every subsequent event. It is now cleared on any identity change. This is a data-quality bug, not a latency one.
  • Heartbeat outside the browser. startHeartBeatProcessor was not window-guarded, unlike the other automatic events here (initialization, session duration, visibility). On a long-lived server-side client it fired a me() every 60s and its interval kept the Node event loop alive. Explicit analytics.track() calls from backend functions are unaffected.

Scope — what this does not fix

Prod data (24h) shows ~88% of the 5.34M daily User/me calls come from the server-side SDK in backend functions, not browsers. This PR removes one request per cold browser load; it does not touch that server-side share, and it does not touch the ~33ms of per-request app-metadata reload that dominates the endpoint's 40ms p50. That work is separate, in apper.

The Datadog p50 will not move on merge day. The SDK is pinned at ^0.8.35 in the app templates, so apps pick this up on their next install or rebuild; the deployed fleet trails.

Testing

npm run test:unit — 199 pass (7 new). Lint, type-tests, and build clean.

The concurrency test uses a single nock interceptor with disableNetConnect, so a second GET fails the test; verified it fails against the unfixed me().

Not covered: no browser-level test that the two GETs actually stop serializing — the fix is asserted at the request-count level.

Noticed, not fixed

flush()'s beacon branch looks inverted: beaconRequest returns true when the beacon cannot be used, so if (!options.isBeacon || !beaconRequest(events)) skips the axios fallback exactly when the beacon failed, and double-sends when it succeeded. Left alone as unrelated to this fix — worth its own look.

🤖 Generated with Claude Code

The analytics module resolves its session context through `auth.me()` at
client construction, which is the same moment most apps issue their own
`me()`. Browsers serialize the two identical GETs, so the second pays the
first's full latency — ~190ms on a cold load (BUG-837).

`auth.me()` now shares its pending promise between concurrent callers.
The promise is cleared as soon as the request settles, so no resolved user
is ever retained: caching identity across requests would leave an app
rendering a logged-in view after logout or a session swap. `setToken` and
`logout` drop the pending request for the same reason — a `me()` already in
flight must not resolve into callers that run after the identity changed.

Deduping inside the analytics module would not have helped; the duplicate
is between analytics and the app, and both go through `auth.me()`.

Two related fixes in the analytics module:

- The session context memoized the resolved `user_id` for the lifetime of
  the session and was never reset, so a visitor who loaded a page
  anonymously and then logged in kept reporting `user_id: null` on every
  subsequent event. It is now cleared on any identity change.
- The heartbeat is browser-only, matching the other automatic events here.
  Outside a browser it fired a `me()` every 60s for the lifetime of a
  long-lived server-side client and kept the Node event loop alive.
  Explicit `analytics.track()` calls are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@guyofeck
guyofeck requested review from OmerKat and roymiloh August 9, 2026 09:59
@guyofeck
guyofeck marked this pull request as ready for review August 9, 2026 09:59
Comment thread src/modules/analytics.ts

@yardend-wix yardend-wix left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM , left a small comment

Both halves of the previous commit published their result unconditionally
after the await, so a reset landing mid-flight was undone by the request it
was meant to invalidate.

getSessionContext wrote the resolved context into shared state even when
resetAnalyticsSessionContext had nulled the promise in the meantime. An
anonymous lookup that settled after login put user_id: null back and pinned
it for the rest of the session — the exact bug the reset exists to prevent.
It now publishes only if it is still the current lookup, and returns the
awaited value either way: those events were queued before the identity
changed, so that is who they belong to.

me() cleared pendingMe from a bare .finally, so a request retired after
setToken had already started a replacement would retire the replacement
instead, and the next caller would issue a duplicate GET. It now clears only
its own entry.

Reported in review on #245.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@guyofeck
guyofeck merged commit 72b2502 into main Aug 16, 2026
guyofeck added a commit that referenced this pull request Aug 18, 2026
* fix(analytics): skip the identity lookup when no token is set

On a public page the analytics module still issued a `User/me` at client
construction: `trackInitializationEvent` queues an event, the processor
flushes the first batch immediately, and `flush` awaits `getSessionContext`,
which calls `auth.me()` unconditionally. With no session that request can
only answer 401, and the browser logs it to the console from the network
layer -- before any handler in the SDK sees the rejection. The existing
`.catch` in `getSessionContext` and the try/catch in `flush` already keep the
rejection from escaping, so no amount of additional catching removes what
users actually see.

Skip the lookup when no access token is set. Anonymous events already
reported `user_id: null` through that same `.catch`, so no analytics data
changes -- the request is dropped only in the case where its sole possible
outcome was a 401.

The dedupe from #245 is unaffected: when a token is present, analytics still
resolves through `auth.me()` and shares the app's in-flight request, so an
authenticated cold load continues to issue exactly one `User/me`.

Token presence is tracked in the auth module rather than read off
`axios.defaults`, so it follows the identity transitions that already exist
there (`setToken`, `logout`) and is seeded from the constructor token, which
is how the server-side SDK carries a session it never sets explicitly. The
skip is deliberately not memoized: a visitor who logs in mid-session must
start resolving an identity again.

This does not cover a stale or expired token, which still 401s. Removing that
one needs `User/me` to answer 200 with a null user instead of 401, which is a
backend change.

Tests: the two analytics tests that exercise identity resolution now build a
token-bearing client; the axios mock gains the `defaults` that `setToken` and
`logout` write through. Three new tests cover the anonymous skip, resolution
resuming after `setToken`, and token presence across identity changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtKFfzaVR6nWAi8tNvJqfw

* refactor(auth): keep hasToken() off the public AuthModule type

Review feedback on #255: despite @internal, hasToken() landed in the
published .d.ts (tsc does not strip @internal members without
stripInternal), so every consumer saw it on base44.auth.

Move it to InternalAuthModule, an extension of AuthModule that only the
factory and the analytics module reference. It is not exported from the
package index, and Base44Client.auth stays typed as AuthModule, so the
runtime object still carries the method but the public type surface is
unchanged from before this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtKFfzaVR6nWAi8tNvJqfw

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants