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:
- Host calls
present().
CheckoutWebViewController.notifyPresented() sets checkoutView.checkoutDidPresent = true.
CheckoutWebView.dispatchPresentedMessage(...) calls CheckoutBridge.sendMessage(webView, messageName: "presented", ...).
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
- Queue messages until
MobileCheckoutSdk is exposed — would require coordinated changes on the JS side as well.
- Defer
dispatchPresentedMessage until a JS-initiated ready message arrives — cleaner architecturally but requires a protocol change.
- 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
- Configure the SDK with
preloading: false.
- Subscribe to
checkoutDidEmitWebPixelEvent on the delegate.
- Open the checkout sheet via
present() (no preload).
- 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).
- 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
What area is the issue related to?
Checkout Sheet Kit
What platform does the issue affect?
iOS
What version of
@shopify/checkout-sheet-kitare you using?3.8.0
Do you have reproducible example code?
Summary
CheckoutBridge.dispatchMessageTemplatecontains a race condition that can cause thepresentedhandshake 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 nowebPixelEventcallbacks for the entire checkout session, includingcheckout_completedand any custom pixel events.The bug is most reliably reproduced with
preloading: falseagainst a checkout whose JS bundle takes longer to initialize than the implicit buffer betweenpresent()anddidFinish(in our case ~6–7s of checkout init including third-party scripts).Workaround
Until a fix is released, set
preloading: truein your SDK configuration. The race does not manifest when the JS bundle is already initialized beforepresent()is called.If
preloading: trueis not viable (e.g., because preload caching causes a separate "checkout fails to render" issue for your storefront), a local patch toCheckoutBridge.dispatchMessageTemplate(see Proposed Fix below) is the only reliable mitigation we have found.Root Cause
The handshake flow is:
present().CheckoutWebViewController.notifyPresented()setscheckoutView.checkoutDidPresent = true.CheckoutWebView.dispatchPresentedMessage(...)callsCheckoutBridge.sendMessage(webView, messageName: "presented", ...).sendMessagecallswebView.evaluateJavaScript(...)with the script produced bydispatchMessageTemplate(body:).The current
dispatchMessageTemplateis roughly:There are three states the script can land in:
MobileCheckoutSdkalready attached towindowmobileCheckoutBridgeReadyis dispatchedmobileCheckoutBridgeReadywas already dispatched butMobileCheckoutSdkis not exposed in the form theifcheck expects (or the dispatch happened in a microtask before our listener attached)State C is the failure mode.
addEventListeneronly catches future events; oncemobileCheckoutBridgeReadyhas been dispatched, attaching a listener after the fact is a no-op. Thepresentedmessage is lost, and because the JS-side pixel forwarder is gated onpresented, no pixel events ever reach native.The race window is narrow but consistently hit when:
preloadingis disabled, so the SDK has no head start initializing the bundle.Evidence
Native instrumentation added at
MessageHandler.userContentController(_:didReceive:),CheckoutWebView.dispatchPresentedMessage(_:_:), and thedidFinishnavigation delegate produced the following pattern for failing sessions (timestamps in seconds since epoch, fractional milliseconds preserved):That is:
evaluateJavaScriptis called, returns no error, but no JS→Native message ever arrives — including thewebPixelsmessages the JS side believes it published (confirmed via the Shopify checkoutconsoleand 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 timepresent()is called, the JS bundle is in state A (already initialized), and theif (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 didFinishfires when the HTML document finishes loading — but before the deferred JS bundle has finished executing and exposingwindow.MobileCheckoutSdk. The SDK immediately firespresentedviaevaluateJavaScript. Whether that script lands in state B or state C depends on subtle timing in the JS event loop.Proposed Fix
Replace the
addEventListenerfallback with a bounded polling loop:Properties
Alternative fixes considered
MobileCheckoutSdkis exposed — would require coordinated changes on the JS side as well.dispatchPresentedMessageuntil a JS-initiatedreadymessage arrives — cleaner architecturally but requires a protocol change.MobileCheckoutSdkfrom Swift viaevaluateJavaScriptrather 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:
preloading: false+ COD (fast checkout)preloading: false+ 2C2P (slower checkout)preloading: true(any path)preloading: false, immediate sheet openNo regressions observed.
Additional Notes / Suggestions
preloading: falseas a fully-supported configuration with the same delivery guarantees aspreloading: true, or (b) explicitly call out that hosts must allow N seconds of buffer beforepresent(). 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.evaluateJavaScriptis called forpresentedso 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
preloading: false.checkoutDidEmitWebPixelEventon the delegate.present()(no preload).paymentOnDelivery, or any in-page completion).checkoutDidEmitWebPixelEventcallbacks 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
didFinishnavigation, by which point the bundle is already initialized, so the SDK's re-sentpresentedhandshake lands and pixels start flowing from that point onward.With
preloading: true, the bug never manifests — the bundle is initialized beforepresent()is called.Expected Behavior
Across all
preloadingconfigurations and all checkout flows,checkoutDidEmitWebPixelEventshould fire on the delegate for every pixel event the JS side publishes — includingcheckout_completedand 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 seconddidFinishnavigation 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