Skip to content

presented handshake race condition silently drops webPixelEvent delivery when preloading: false #490

@truongtd-mobile-dev

Description

@truongtd-mobile-dev

What area is the issue related to?

Checkout Sheet Kit

What platform does the issue affect?

iOS

What version of @shopify/checkout-sheet-kit are you using?

3.8.0

Do you have reproducible example code?

Summary

CheckoutBridge.dispatchMessageTemplate contains a race condition that can cause the presented handshake message — and any subsequent SDK→Web message sent before the JS bundle is ready — to be silently dropped. When this happens, the JS-side pixel forwarder is never activated, so the host app receives no webPixelEvent callbacks for the entire checkout session, including checkout_completed and any custom pixel events.

The bug is most reliably reproduced with preloading: false against a checkout whose JS bundle takes longer to initialize than the implicit buffer between present() and didFinish (in our case ~6–7s of checkout init including third-party scripts).

Workaround

Until a fix is released, set preloading: true in your SDK configuration. The race does not manifest when the JS bundle is already initialized before present() is called.

If preloading: true is not viable (e.g., because preload caching causes a separate "checkout fails to render" issue for your storefront), a local patch to CheckoutBridge.dispatchMessageTemplate (see Proposed Fix below) is the only reliable mitigation we have found.

Root Cause

The handshake flow is:

  1. Host calls present().
  2. CheckoutWebViewController.notifyPresented() sets checkoutView.checkoutDidPresent = true.
  3. CheckoutWebView.dispatchPresentedMessage(...) calls CheckoutBridge.sendMessage(webView, messageName: "presented", ...).
  4. sendMessage calls webView.evaluateJavaScript(...) with the script produced by dispatchMessageTemplate(body:).

The current dispatchMessageTemplate is roughly:

if (window.MobileCheckoutSdk && window.MobileCheckoutSdk.dispatchMessage) {
    window.MobileCheckoutSdk.dispatchMessage(<body>);
} else {
    addEventListener('mobileCheckoutBridgeReady', function () {
        window.MobileCheckoutSdk.dispatchMessage(<body>);
    }, { once: true });
}

There are three states the script can land in:

State Description Outcome
A MobileCheckoutSdk already attached to window Dispatch lands immediately. ✓
B Bundle not yet executed; listener attaches before mobileCheckoutBridgeReady is dispatched Listener fires when bundle initializes. ✓
C mobileCheckoutBridgeReady was already dispatched but MobileCheckoutSdk is not exposed in the form the if check expects (or the dispatch happened in a microtask before our listener attached) Listener never fires. ✗

State C is the failure mode. addEventListener only catches future events; once mobileCheckoutBridgeReady has been dispatched, attaching a listener after the fact is a no-op. The presented message is lost, and because the JS-side pixel forwarder is gated on presented, no pixel events ever reach native.

The race window is narrow but consistently hit when:

  • preloading is disabled, so the SDK has no head start initializing the bundle.
  • The checkout's JS init is non-trivial (large vendor bundles, custom pixels, third-party tag managers).
  • The host application adds its own short delay between preload and present (or skips preload entirely).

Evidence

Native instrumentation added at MessageHandler.userContentController(_:didReceive:), CheckoutWebView.dispatchPresentedMessage(_:_:), and the didFinish navigation delegate produced the following pattern for failing sessions (timestamps in seconds since epoch, fractional milliseconds preserved):

[SDK NAV FINISH]            ts=...801
[CheckoutWebView.checkoutDidLoad didSet → true]
[SDK PRESENTED CHECK] checkoutDidLoad=true checkoutDidPresent=true isBridgeAttached=true willSend=true
[SDK PRESENTED SEND]        ts=...802   evaluateJavaScript("...'presented'...")
... no further [CHECKOUT NATIVE MSG] for the remainder of the session ...

That is: evaluateJavaScript is called, returns no error, but no JS→Native message ever arrives — including the webPixels messages the JS side believes it published (confirmed via the Shopify checkout console and the third-party pixel server-side: the events are fired on the web side; they just never cross the bridge).

On the same build with preloading: true, the same instrumentation shows [CHECKOUT NATIVE MSG] name=webPixels ... arriving normally during the same checkout flow.

Why preload masks the bug

With preloading: true, the SDK loads the checkout webview before the user reaches the checkout step. By the time present() is called, the JS bundle is in state A (already initialized), and the if (window.MobileCheckoutSdk && ...) fast path hits. The race never occurs.

With preloading: false, present() is the first time the webview navigates to the checkout URL. webView didFinish fires when the HTML document finishes loading — but before the deferred JS bundle has finished executing and exposing window.MobileCheckoutSdk. The SDK immediately fires presented via evaluateJavaScript. Whether that script lands in state B or state C depends on subtle timing in the JS event loop.

Proposed Fix

Replace the addEventListener fallback with a bounded polling loop:

static func dispatchMessageTemplate(body: String) -> String {
    return """
    (function() {
        function __tryDispatch() {
            if (window.MobileCheckoutSdk && window.MobileCheckoutSdk.dispatchMessage) {
                window.MobileCheckoutSdk.dispatchMessage(\(body));
                return true;
            }
            return false;
        }
        if (!__tryDispatch()) {
            var __attempts = 0;
            var __intv = setInterval(function() {
                if (__tryDispatch() || ++__attempts > 300) {
                    clearInterval(__intv);
                }
            }, 100);
        }
    })();
    """
}

Properties

  • Race-free: polling does not depend on catching a transient event. If the bridge is ever exposed during the polling window, the dispatch lands.
  • Idempotent: dispatch runs exactly once; the interval is cleared on first success.
  • Bounded: 300 attempts × 100ms = 30s ceiling for slow networks / heavy third-party bundles. Tunable.
  • Low overhead: 10 polls/sec for at most the duration of bundle init (typically <1s).
  • Backwards-compatible: the fast path (state A) is unchanged; only the fallback semantics differ.

Alternative fixes considered

  1. Queue messages until MobileCheckoutSdk is exposed — would require coordinated changes on the JS side as well.
  2. Defer dispatchPresentedMessage until a JS-initiated ready message arrives — cleaner architecturally but requires a protocol change.
  3. Poll for MobileCheckoutSdk from Swift via evaluateJavaScript rather than embedding the loop in the injected script — same effect, more native ↔ JS round-trips.

The embedded-poll approach above is the smallest, most surgical change.

Verification

Applied as a local patch on top of v3.8.0:

Scenario Before After
preloading: false + COD (fast checkout) No pixel events All pixel events delivered ✓
preloading: false + 2C2P (slower checkout) Usually works Still works ✓
preloading: true (any path) Works Works ✓
preloading: false, immediate sheet open No pixel events All pixel events delivered ✓

No regressions observed.

Additional Notes / Suggestions

  • It would be valuable to either (a) document preloading: false as a fully-supported configuration with the same delivery guarantees as preloading: true, or (b) explicitly call out that hosts must allow N seconds of buffer before present(). Currently the failure mode is silent — the only symptom is missing pixel events, which is easy to misattribute to the host's pixel configuration or the Shopify admin.
  • Consider adding a one-line debug log when evaluateJavaScript is called for presented so that hosts can correlate handshake timing in their own logs without recompiling the SDK.

Happy to open a PR with the proposed fix if it would help.

Steps to Reproduce

  1. Configure the SDK with preloading: false.
  2. Subscribe to checkoutDidEmitWebPixelEvent on the delegate.
  3. Open the checkout sheet via present() (no preload).
  4. Complete checkout via a payment method that does not redirect away from the Shopify checkout (e.g., Cash on Delivery / paymentOnDelivery, or any in-page completion).
  5. Observe: no checkoutDidEmitWebPixelEvent callbacks fire — neither for built-in events (page_viewed, checkout_completed) nor for custom pixels.

The bug is not a function of how long the user lingers in checkout. We verified that even waiting >10 seconds on the checkout page before completing via COD produces zero pixel events.

Payment flows that do redirect externally (e.g., 3DS, hosted gateway / 2C2P) happen to mask the bug: the redirect causes a second didFinish navigation, by which point the bundle is already initialized, so the SDK's re-sent presented handshake lands and pixels start flowing from that point onward.

With preloading: true, the bug never manifests — the bundle is initialized before present() is called.

Expected Behavior

Across all preloading configurations and all checkout flows, checkoutDidEmitWebPixelEvent should fire on the delegate for every pixel event the JS side publishes — including checkout_completed and custom pixels.

  • preloading: false + in-page completion (e.g., Cash on Delivery): pixel events delivered.
  • preloading: false + external-redirect completion (3DS / hosted gateway): pixel events delivered.
  • preloading: true: pixel events delivered.

Actual Behavior

  • preloading: false + in-page completion (COD): zero pixel events delivered for the entire session. Native delegate never fires, regardless of how long the user lingers in the checkout sheet before completing.
  • preloading: false + external-redirect completion: pixels are dropped on the initial load, but the redirect triggers a second didFinish navigation which lets the SDK recover the bridge. Pixels fired after the redirect are delivered; pixels fired before it are lost.
  • preloading: true: pixel events delivered as expected.

Storefront domain

https://ssp-staging.myshopify.com/

Screenshots/Videos/Log output

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions