Skip to content

[BUG] Type=2 /analytics/ requests get cancelled mid-flight when user navigates away quickly #1895

Description

@subodhr258

Bug Report

Replacing the original write-up. The previous diagnosis ("duplicate requests") was wrong — duplicates are deduplicated server-side in heartbeat processing. The real bug is that some /analytics/ POSTs never reach the server when the user navigates away quickly.

Current Behavior

When the user triggers a type=2 (video heatmap) event and then immediately navigates to another page, the corresponding POST /analytics/ request is initiated in the browser but is cancelled by the browser as the page tears down — before it ever reaches the server. In DevTools → Network, these show up alongside normal {} JSON responses but with the blank-document icon (no response body / cancelled), e.g. the two highlighted rows in the screenshot below:

Two `/analytics/` rows with blank-document icon — cancelled, no response

The request payload is well-formed (and keepalive: true is set), but the request never lands. Reproducible on palak-godam.rt.gw by interacting with a Reel Pops modal and clicking a nav link before the modal fully closes / before the next tick of the analytics async pipeline.

Sample payload of a cancelled request (from the Reel Pops release page):

{
  "site_url": "https://palak-godam.rt.gw",
  "user_token": "83c1aa4a-7b06-4bef-b087-f83b463c1c03",
  "wp_user_id": 0,
  "account_token": "cuunl3cre4",
  "visitor_timestamp": 1779795970929,
  "visit_entry_action_url": "https://palak-godam.rt.gw/reel-pops-release/",
  "visit_entry_action_name": "Reel Pops release – palak-godam.rt.gw",
  "referer_url": "https://palak-godam.rt.gw/2026/05/15/shoppable-video-godam-video-product-gallery-block/",
  "config_resolution": "799x836",
  "config_os": "Macintosh",
  "config_browser_name": "Google Chrome",
  "is_page": true,
  "post_id": 462,
  "post_title": "Reel Pops release",
  "type": 2,
  "video_id": 457,
  "ranges": [[0, 0]],
  "video_length": 0,
  "job_id": "50uc96k012"
}

(Side note: ranges: [[0, 0]] + video_length: 0 is also worth tightening — a heatmap with zero-duration ranges and an unloaded duration is not a meaningful play event. See "Secondary cleanup" below. The primary bug is the request loss, not this payload shape.)

Expected Behavior

For every analytics.track('video_heatmap', …) call that survives the property-validation checks in trackVideoEvent (player exists, ranges non-empty, video id present), the POST /analytics/ request should reach the server — even if the user navigates away in the very next click.

Root cause

The analytics library path is async all the way down:

  1. window.analytics.track('video_heatmap', …) calls Analytics from the analytics npm package (v0.8.18, @analytics/core v0.13.1).
  2. The library dispatches a trackStart action through its Redux store. Each middleware (le in @analytics/core) wraps the call in Promise.resolve(…).then(…) chains — meaning the call to videoAnalyticsPlugin.track happens at least one microtask later.
  3. The plugin's track is itself async, so the fetch() call is at least one more microtask after that.
  4. Only at that point does fetch() get called, and only then does keepalive: true start protecting the request.

If the user clicks a link before step 4 runs, the browser begins unloading the page and the Promise chain is silently abandoned — fetch() is never called, so there is no in-flight keepalive request to preserve.

The plugin author was already aware of this exact failure mode and worked around it for the unload path. See the comment in sendPlayerHeatmap():

window.analytics.track() dispatches async work (Promises, microtasks). The browser does NOT block page unload waiting for async work to settle — the async chain can be silently abandoned mid-flight. The correct fix is to call fetch() with keepalive: true SYNCHRONOUSLY inside the handler.

…and sendPlayerHeatmap accordingly bypasses window.analytics.track() to call fetch() directly from beforeunload/pagehide/visibilitychange/dispose. But the same bypass is not applied to trackVideoEvent callers, even though those callers (notably the Reel Pops modal) are extremely likely to be followed by a navigation. Example caller — godam-for-woo/assets/src/js/reel-pops/reel-pops-frontend.js:338-346:

beforeRestore: ( { item } ) => {
    item.classList.remove( 'is-in-modal' );
    const video = item.querySelector( 'video' );
    if ( video && window.analytics?.trackVideoEvent ) {
        window.analytics.trackVideoEvent( {  // ← async pipeline, can be lost
            type: 2,
            videoId: parseInt( video.getAttribute( 'data-id' ), 10 ),
            root: item,
            reelPopId: this.reelPopId,
            sendPageLoad: false,
        } );
    }
},

If the user closes the modal (which triggers beforeRestore) and clicks a nav link in the same gesture, the type=2 fetch never gets initiated.

Steps to Reproduce

  1. Open palak-godam.rt.gw/reel-pops-release/ in Chrome.
  2. Open DevTools → Network tab, filter by ana, enable Preserve log.
  3. Click the Reel Pops widget to open the modal, optionally swipe between reels.
  4. Close the modal and immediately click a link to navigate to another page (do both within ~500 ms).
  5. In the Network tab, observe one or more /analytics/ rows with the blank-document icon (cancelled — no response body). Inspect their Timing → "Stalled" or "(canceled)" status.

Possible Solution

The cleanest fix is to mirror the sendPlayerHeatmap approach for the trackVideoEvent path: bypass the analytics library and issue fetch( … , { keepalive: true } ) synchronously inside the caller's gesture.

Concretely:

  • Extract the shared body construction into a synchronous helper (it already is — see buildAnalyticsRequestBody in analytics-helpers.js).
  • Have trackVideoEvent call fetch() directly with keepalive: true for type=2, instead of going through window.analytics.track(). The videoAnalyticsPlugin would then only handle non-critical events (or be removed entirely if heatmap is the only thing it serves).
  • Or, expose a window.GoDAM.sendHeatmapNow( player, video ) synchronous API the Reel Pops modal can call from beforeRestore — same as sendPlayerHeatmap does internally.

Either way the win is the same: fetch() is in the call stack of the user gesture, so the request is queued at the network layer before unload begins, and keepalive: true is honored.

Secondary cleanup (related, low priority)

  • collectPlayedRanges() (analytics.js:28-37) emits [[0, 0]] when videojs.player.played() reports a single zero-length range (happens when play() was called but no frames rendered). trackVideoEvent then proceeds because ranges.length === 1, not 0. Tightening the guard to ranges.every(([s, e]) => e > s) (or a min-duration threshold) would drop these phantom heatmap events at the source — and would also reduce the number of cancellable in-flight requests during quick navigation.

Environment

  • Site: https://palak-godam.rt.gw
  • Browser: Google Chrome 148.0.0.0 on macOS (per config_token)
  • Account: cuunl3cre4
  • Visitor: anonymous (wp_user_id: 0)
  • Reel Pops widget enabled (godam-for-woo).

Reported & investigated with help from Claude (via Claude Code).

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions