[Bug] fetch() hangs on iOS in pos.purchase.post.action.render

We have a POS UI Extension registered on pos.order-details.action.render and pos.purchase.post.action.render. Both targets use identical fetch() logic hitting the same backend endpoints. In fact, they use the exact same modal, so literally the same logic.

On iOS only, fetch Promises on the post-purchase target hang indefinitely; they never resolve or reject.

Our server is not the problem. Multiple clients hit the same API with sub-second response times. Server logs confirm every request from the iOS extension arrives and gets a response. The order-details target, in the same app on the same device, works perfectly. The hang only occurs on pos.purchase.post.action.render.

iOS only. iPadOS and Android both work fine on both targets. Same code, same backend; only iOS post-purchase hangs.

The response never reaches JS. The request leaves the device and hits our server (confirmed via logs). Our server responds. But the fetch() Promise stays pending forever. The request path works; something in the response path between NSURLSession and the JS Promise is dropping it.

AbortController doesn’t help. Calling controller.abort() on a hung fetch does not cause the Promise to reject with AbortError; it just stays pending. The abort signal isn’t propagating to the native layer.

Given that the order-details target works perfectly with the same code, this looks like something specific to the post-purchase target’s JS runtime or WebView lifecycle on iOS. We don’t have native-side visibility to narrow it further.

Seems similar to this, which is marked as resolved

  1. Is there a known issue with fetch responses not surfacing to JS Promises in the post-purchase target on iOS?
  2. Is there a runtime/WebView lifecycle difference between pos.order-details.action.render and pos.purchase.post.action.render on iOS?

Hi, thanks for the detailed report. To answer your questions,

  1. No currently known issue with fetch from pos.purchase.post.action.render.
  2. Yes, there are lifecycle differences between pos.order-details.action.render and pos.purchase.post.action.render because they’re entered from different
    parts of POS. That said, fetch() should still resolve or reject on both targets while the extension target is mounted.
    i.e.- If the user navigates away, dismisses the modal, or the post-purchase screen tears down while a request is still in flight, the extension instance may be terminated and the pending Promise should not be expected to complete.

The key thing to confirm is whether the Promise hangs while the post-purchase modal remains open and mounted. Can you help share some details which helps narrow it down:

  • Shopify POS app version
  • iOS version and device model
  • POS UI Extensions API version
  • App ID.
  • Approximate timestamp when it happened?
  • Are the iOS/Android devices in the same location/network set up?
  • Whether this reproduces against a known public HTTPS endpoint?

In the meantime, I’d recommend keeping a UI-level timeout around this flow so merchants aren’t left on an indefinite loading state.
Thanks!

If you connect the debugger you can step through the request and response code. That might help incidate the source of the issue Development and debugging

Hey @h-shopify, thank you for the reply.

We’re fairly sure that this is not a matter of timing on modal dismissal, it is repeated behaviour with an indefinitely timed hang, always on the first mounting of the post-purchase modal, for ALL asynchronous processes.

The hanging behaviour is not limited to network requests; it’s all asynchronous promises which do not resolve, meaning a UI interrupt with a timeout also does not work, because the timeout itself never resolves. As I said in the initial comment, we see that the network requests are arriving to the server, but it seems that the Javascript run time within that modal is never resolving the response. We have tried manual promise resolution, forced timeouts, controller aborts, none of which have fixed the indefinite hang.

iOS version is 26.5.1,
iOS device is 17
Shopify POS app version is 11.8.5

@h-shopify just following up here

We’re hitting the same thing from a different app, also iOS only, also working fine on Android. A few findings that may help narrow it down, since some of them contradict the explanations we’ve been given.

**The same code works from every other surface.** Our extension declares targets on the order details screen, the Smart Grid and post-purchase, and they render the same components through the same hooks against the same endpoints — the entrypoint files differ by a single line, a diagnostic label. Creating and voiding a record completes normally from the order details screen and from the Smart Grid modal, on the same device, in the same session. Only post-purchase fails. Two measurements make the contrast concrete:

- In the Smart Grid modal, a response that took over ten seconds resolved without any trouble.

- We ran the same `setInterval` probe on both surfaces. On the order details block it fired on schedule, with drift in the single and double digits of milliseconds. On the post-purchase action target it never fired once.

**Request duration isn’t the variable.** Some of our failing requests complete in well under a second and the Promise still never settles. In one instrumented print attempt, our own fetch returned in under a second, POS went on to download the PDF, and then `api.print.print()` never resolved and the native print dialog never appeared.

**Timers don’t fire either, so a UI-level timeout can’t rescue it.** We instrumented a repeating timer plus deadlines at 5 s and 20 s. With the target still mounted, and after waiting a long time, none of them ever ran — no timer callback, no deadline, no repaint. So keeping a UI-level timeout around the flow isn’t implementable on this target.

**Leaving the screen doesn’t help.** This is the part we haven’t seen tested elsewhere. We tried navigating out of the post-purchase modal with the `shopify:point-of-sale/orders/` deep link. Navigation works and we land on the order details screen — the surface that normally works — and registering there still hangs. We also tried `window.close()`: the modal closes and the hang persists.

So it doesn’t appear to be the modal, and it doesn’t appear to be the screen. It looks like the post-purchase flow itself, still active in the POS session, starving the shared JS runtime regardless of where the user navigates.

**A workaround, in case it helps anyone else hitting this:** if the cashier ends the post-purchase flow natively first (New sale) and only then opens the extension from another surface, everything completes normally. We ended up moving the operation off the post-purchase target entirely, and that has been reliable on iOS.

**Details requested above:**

- **Shopify POS app version:** 11.11.0

- **iOS version and device model:** the instrumented session ran on an iPhone13,4. Worth noting that the user agent we receive reports two different system versions, and we’re passing both on verbatim without interpreting them: `iPhone OS 18_7` in the WebKit segment, and `iOS/26.5.2` in the POS segment. Full string:

`Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Shopify POS/11.11.0/iOS/26.5.2/Apple/iPhone13,4/production ExtensibilityHost`

The deep link and `window.close()` tests described above were run on an iPad.

- **POS UI Extensions API version:** `api_version = “2026-07”`, with `@shopify/ui-extensions` pinned exactly to `2026.7.1`.

- **Does this reproduce against a known public HTTPS endpoint?** Our backend is a public HTTPS endpoint with a valid certificate — not a tunnel — and the requests arrive and are answered normally. We haven’t tried a third-party endpoint yet, and can do so if it’s useful. Worth pointing out that the print case involves no request of ours at all: `api.print.print()` is your own API, and it hangs the same way, which is hard to explain as a problem with our endpoint.

Given all of the above — two independent apps, sub-second requests, and a mounted target where nothing asynchronous ever completes — is this still considered no currently known issue? The statement that `fetch()` should resolve or reject while the extension target is mounted doesn’t hold in our measurements.

Happy to share the instrumented traces with anyone from the POS extensions team who wants them.

We’re hitting this same bug, with one extra detail that may help narrow it down: the hung promise resolves as soon as the user touches the screen.

Environment

  • Device: iPhone 17 Pro
  • Shopify POS app: 11.11.1
  • POS UI Extensions API version: 2026-04 (@shopify/ui-extensions 2026.4.4)
  • Extension targets: pos.purchase.post.action.menu-item.render + pos.purchase.post.action.render (Preact + Polaris web components)

What the extension does

Our app generates Mexican electronic invoices (CFDI). From the post-purchase screen, staff open our modal, fill in a short fiscal-data form, and tap a button. The handler then makes 3 sequential fetch calls to our backend: two quick GETs (~200–500 ms each) and a final POST that takes ~3 seconds server-side.

Symptom

  • The two quick GETs at the start of the handler resolve normally, with no user interaction in between.
  • The final ~3 s POST reaches our backend, which processes it successfully and sends the response (confirmed in server logs).
  • The extension then freezes: the promise for that fetch never resolves, and our loading state stays on screen indefinitely.
  • The moment the user taps any input in the modal, execution resumes: the queued continuation runs and the success/error UI renders immediately.

That wake-on-touch behavior makes it look like the webview’s JS runtime is being suspended while idle in a long await, with pending microtasks flushing only when a native touch event wakes it. It would also explain why short requests early in the flow are unaffected — they complete before whatever idle threshold triggers suspension.

This is consistent with the original report: server responds fine, promise never settles, and a timer-based timeout wouldn’t help since the whole runtime is paused.

Happy to provide a minimal reproduction or logs if that helps.