DEV Community

Mykola Kondratiuk
Mykola Kondratiuk

Posted on

Porting my mobile app to the web: 3 silent bugs that only exist in the browser

I'm a solo founder. My app is a Flutter + Supabase thing that picks your dinner for you. It started on iOS, where it worked. Then I put it on the web.

"Put it on the web" sounds like a build target. It's mostly true: the same Dart compiles, the same screens render. But the browser is a different planet with different physics, and three of my bugs only exist there. A user gesture that expires. A session that leaks across tabs. A checkout someone else has to approve.

The thing they had in common is the thing that makes web bugs so nasty: none of them crashed. No red screen, no stack trace, no error in the console. The app just quietly did the wrong thing while every log said 200 OK.

Here are the three, with the mechanism and the fix for each.


1. The button that did nothing: a pop-up dies if you await first

The "Manage subscription" button on the web did absolutely nothing. No error. No crash. You tapped it, and nothing opened. Every single time.

I went to the logs braced for a 500. Instead, every click produced a clean POST 200 from my server with a perfectly valid customer-portal URL. The data was right. The function was right. The tab just... never opened. And the browser API I used to open it returned true, so my own code thought it had succeeded and never showed an error.

Here's the rule I'd forgotten: a browser only lets you open a new tab during a real user gesture. The click. Not 50ms after the click. The moment your handler does await and waits on the network, the browser decides the gesture is over and silently blocks any pop-up you try to open afterward. Safari is the strictest about this.

My code did the obvious-looking thing: fetch the URL, then open it.

// ❌ pop-up blocked: the await "spends" the user gesture
button.onClick(async () => {
  const url = await fetchPortalUrl();   // network round-trip
  window.open(url, '_blank');           // browser: "what click? blocked."
});
Enter fullscreen mode Exit fullscreen mode

The fix is to open the tab synchronously, inside the gesture, while it's still valid, and only then point it at the URL once the network call comes back:

// ✅ open the tab on the gesture, redirect it after
button.onClick(async () => {
  const tab = window.open('', '_blank'); // opened inside the click
  const url = await fetchPortalUrl();
  if (tab) tab.location = url;           // redirect the tab you already own
  else showError();                      // popup blocker said no up front
});
Enter fullscreen mode Exit fullscreen mode

On mobile this whole class of bug doesn't exist. You call a native "open URL" API and the OS just does it. The web has a security model that says "prove a human asked for this," and an await quietly fails that proof.

Apply it: if a web button "does nothing," it isn't always broken. Anything that opens a tab, a window, or a file picker must happen synchronously on the gesture. Need server data first? Open the blank tab on the click, fetch, then redirect it. And don't trust a launcher that returns true to mean "the user actually saw something."


2. The login that hijacked the whole app

This one made paying members look like they'd never paid, and it took me a minute to even believe what the logs were telling me.

On the web, "restore my subscription" emails you a code. To keep that flow clean, I ran it on a separate, isolated auth client so it couldn't disturb the main app. Sign in over there, verify the purchase, done. The main app, with its own anonymous guest identity, shouldn't even notice.

It noticed.

The auth library stores its session in browser storage under a key derived from the backend host (sb-<host>-auth-token) and broadcasts session changes to every client on that origin through a BroadcastChannel. The channel is keyed by the host, not by which client opened it. So when my "isolated" restore client signed in, that sign-in was broadcast to the main client, which dutifully saved it and dropped the device's own anonymous identity.

Now the device was a different user. And because my row-level security ties every row to the current identity:

  • the premium read came back with no row → the app showed free;
  • writing the user's own settings started failing with permission denied.

The logs were almost comically precise about it. The email login landed at 08:10:21. The first permission failures started at 08:10:35. Fourteen seconds from "isolated login" to "the app is lying to a paying customer." The restore had actually succeeded on the server the whole time. The client just quietly clobbered itself.

Two fixes, because there were two problems:

  1. Root cause: snapshot the device's real anonymous session before the code flow, and restore it the instant the flow finishes, no matter how it finishes (success, failure, whatever). Re-applying the saved token re-broadcasts the right identity and it wins.
  2. Defense in depth: treat a membership row I can't read as unknown, never as a downgrade. A real cancellation returns a readable row that says "not premium." An empty result means "I don't know," and "I don't know" must never silently flip someone to free.

On mobile, none of this happens. There's no shared BroadcastChannel, no per-origin token in a storage bucket every tab can see. The web's gift is that "separate client" is a polite fiction. Global state you didn't know was global is the most expensive kind.

Apply it: on the web, auth/session state is frequently shared across the whole origin, not scoped to the object you instantiated. Before you spin up a "second, isolated" client, find out what storage key and what broadcast channel it uses. And anywhere a missing read could change a user's status, encode the difference between "the answer is no" and "I couldn't get an answer."


3. The checkout my payment processor wouldn't approve

The last one isn't a code bug at all. It's the kind of wall you only hit on the web, and it killed a launch.

To take subscription payments on the web, I'd wired in a subscriptions tool whose checkout page is hosted on their domain. Clean in theory: send the user to their hosted page, they handle the card, you get a webhook. The catch is that the company that actually moves the money, my payment processor, requires the checkout to run on a domain they've approved for my account. And they wouldn't approve a checkout page sitting on a third party's domain.

So the elegant hosted-checkout path was a dead end before a single real card touched it. Not because of a bug in my code. Because of where the page lived.

The fix was to remove a layer, not add one. I dropped the hosted-checkout middleman on the web and integrated the payment processor directly, as an overlay on my own already-approved domain. The iPhone build never changed (it uses native in-app purchases and never touched any of this). Only the web path got simpler by getting shorter.

The lesson generalizes way past payments: on the web especially, every service you bolt on "to make things easier" is also a new party who can say no, on their schedule, for their reasons. A hosted page on someone else's domain, an embed that needs allow-listing, an OAuth app pending review. Each is a dependency you don't control, sitting on your critical path.

Apply it: when you add a third party to your money path, check whose domain the user actually transacts on and who has to approve it, before you build around it. And keep your integration shallow enough that "rip out the middleman and go direct" stays a one-day change, not a rewrite.


The pattern

Three bugs, one shape. The browser shares more state than you think (a login leaked across "isolated" clients), trusts you less than you think (a pop-up needs a live human gesture), and hands you less control than you think (a checkout you don't get to host). And not one of them announced itself. They returned 200s and trues and blank screens while the actual product quietly broke.

Mobile lets you reach for the OS and mostly get a yes. The web makes you prove intent, share an origin, and ask permission. Porting isn't recompiling. It's relearning the platform's rules, usually one silent failure at a time.


What I'm building

The app is SomeYum, and it takes the nightly "what do I eat today?" decision off your plate. You swipe through a handful of dishes (not 200), say yes to one, and get the recipe. It learns your taste from what you swipe, speaks 15 languages, and now it runs on the web too, browser physics and all. Free to use; premium is $4.99/mo or $29.99/yr, no trial games.

If you've shipped a mobile app to the web: which browser-only rule bit you first? Pop-ups, storage, CORS, gestures? The comments are where I'm collecting these. 👇

Top comments (0)