DEV Community

Ted
Ted

Posted on • Originally published at tedagentic.com

Chrome Showed the Data. Firefox Showed Nothing. The API Was Being Blocked.

I was looking at the same page in two browsers side by side. In Chrome: a full list of results, images, working buttons. In Firefox: a different, smaller set of placeholder cards with no images and no links — and a banner that read "0 results found."

Same URL. Same deployed build. No "works on my machine" excuse, because it wasn't working on the other machine — it was the same machine, two windows.

That contradiction is the whole story, and the cause was a class of bug that is almost impossible to catch in development: the browser was blocking my own API call, and I'd been developing in the one browser that didn't.

How the page is built

The page renders a list of results that come from a database. The pattern is the common one for a single-page app: the page ships, then a bit of JavaScript runs and fetches the data from a hosted Postgres/API service (Supabase, in this case) at its own domain — something like xxxx.supabase.co. The results come back, React renders the cards.

If that fetch returns nothing, the component falls back to a hardcoded placeholder list so the page isn't blank. That fallback is what Firefox was showing. So the real question was narrow: why did the fetch return nothing in Firefox but everything in Chrome?

The diagnosis

The server was fine. I could hit the API directly from the command line and get the full result set, HTTP 200. The database, the credentials, the query — all correct. Chrome proved it too: it ran the exact same JavaScript against the exact same endpoint and got the data.

The difference was the browser, and specifically what the browser allowed. Firefox ships with Enhanced Tracking Protection, and a lot of people add uBlock Origin or Privacy Badger on top. Those tools block requests to domains they classify as third-party trackers. The relevant move is in the browser's network panel: the request to xxxx.supabase.co wasn't failing with an error from the server — it was being blocked before it ever left, by the privacy layer.

From my code's point of view, the fetch just throws. data comes back empty. The component does what I told it to do with no data: show the fallback. No crash, no red console error a casual glance would notice. The page "works." It just works with nothing in it.

The whole bug fits in two columns:

   Chrome                      Firefox + tracking protection

   Page                        Page
    ↓                           ↓
   Fetch supabase.co           Fetch supabase.co
    ↓                           ↓
   Data arrives                Blocked
    ↓                           ↓
   Render results              Fallback cards
                                ↓
                               0 results
Enter fullscreen mode Exit fullscreen mode

Same code reaching the same fork, and the third step decides everything.

Why this is so easy to ship

Here's the uncomfortable part. I build in Chrome. Most developers build in Chrome. Chrome, with no aggressive privacy extensions, happily makes the cross-origin call, so the page is perfect every single time I look at it. The failure only appears in an environment I never use during development.

And it gets worse, because the measurement is blocked too. My click tracking — the thing that tells me whether people are using the page — went through the same API domain. So the users who couldn't see the content also couldn't be counted not using it. The bug hides in the browser I don't test, and it erases its own evidence in the analytics. It's invisible twice.

The only reason I found it is that someone opened the page in Firefox and said "this looks broken."

Fix one: stop the fallback being a dead end

The first thing I did was cheap and defensive. The placeholder fallback had no links — a blocked user landed on dead cards and had nowhere to go. So I made every fallback card and every "quick pick" link to the real directory page. Now, whatever blocks the fetch, the visitor still has a working path forward.

This is worth doing regardless, but be honest about what it is: a safety net, not a fix. The user is still being denied the real content. They're just no longer stranded. To actually fix it, the call has to stop being blockable.

Fix two: make the call first-party

It's worth being precise about what I'm fixing here, because it's easy to mislabel. This isn't a Supabase problem — Supabase did everything right. It's a browser trust-boundary problem. The browser draws a line between "the site I'm on" and "some other domain," and privacy filters police that line. My data was on the wrong side of it.

So the fix isn't to change the database. It's to move the request to the trusted side of the line — to change what the browser sees:

   Before                       After

   mysite.com                   mysite.com
    ↓                            ↓
   xxxx.supabase.co             mysite.com/sb-api
   (third-party — policed)      (first-party — trusted)
Enter fullscreen mode Exit fullscreen mode

Same data, same backend. The only thing that changed is whose domain the browser thinks it's talking to. You do that with a reverse proxy: route the API through your own domain so the browser only ever sees a same-origin request to the site it's already on. On Vercel that's a rewrite in vercel.json:

{
  "source": "/sb-api/:path*",
  "destination": "https://xxxx.supabase.co/:path*"
}
Enter fullscreen mode Exit fullscreen mode

Then point the client at the first-party path instead of the vendor's domain:

const SUPABASE_URL =
  import.meta.env.PROD && typeof window !== "undefined"
    ? `${window.location.origin}/sb-api`
    : RAW_SUPABASE_URL; // dev + build-time stay on the direct URL
Enter fullscreen mode Exit fullscreen mode

Now the browser makes a request to mysite.com/sb-api/..., which Vercel quietly forwards to the real API. To Firefox's tracking protection it's a call to the site you're already visiting — first-party, not a tracker, not blocked. As a bonus, the browser no longer has to negotiate cross-origin access at all.

I verified it the same way I found the bug — by hitting the new first-party URL and watching the real data come back through it — then opened the page in Firefox and got the full list, images and all, identical to Chrome.

The gotchas, because there are always gotchas

A reverse proxy in front of your whole data layer is a real change. Three things to check before you ship it:

  • Websockets don't ride through a simple rewrite. If you use the service's realtime/subscription features, a path rewrite won't proxy the socket cleanly. I got to skip this because the app uses none — but check first, or you'll trade a Firefox bug for a realtime outage.
  • Pin your auth storage key. Client libraries often derive the key they store the session under from the API URL. Change the URL and the key changes, and everyone silently gets logged out. Set the storage key explicitly to the original value so swapping the URL is invisible to existing sessions.
  • Keep server-side and build-time calls on the direct URL. Anything running outside the browser — a build step, prerendering, server code — has no ad blocker and no same-origin to honor. Sending it through the proxy is pointless and can be circular. Gate the rewrite to the browser only.

The crawler is just another blocked client

There's a tidy way to think about all of this. A browser with tracking protection is a client that won't run part of your page. A search crawler is also a client that won't run part of your page — it may not execute your fetch at all. Both end up looking at the empty shell.

So the durable answer isn't only the proxy; it's not making the page's existence depend on a fetch that some clients will never complete. I also prerender the real results into the page's static HTML at build time, so the meaningful content is in the document before any JavaScript — for crawlers, for blocked browsers, for anyone whose script never runs. The proxy fixes the live experience; prerendering makes sure there's something real there even when nothing runs at all.

What I took from it

  • A page that's perfect in Chrome can be empty in Firefox. If your data comes from a client-side call to a third-party domain, privacy filters will block it for a real slice of users, and you'll never see it in the browser you develop in.
  • Silent failure is the dangerous kind. A blocked fetch doesn't crash — it just renders your empty state. Make the empty state recoverable, but don't mistake that for a fix.
  • The blocked users are uncounted users. If your analytics and your content share an API domain, the people who can't see the page also don't show up as not seeing it. Test in Firefox with tracking protection on, and with an ad blocker — that's your blind spot, on purpose.
  • First-party proxy beats third-party fetch. Route vendor APIs through your own domain so the browser sees same-origin. Mind websockets, pin the auth key, keep build/server calls direct.
  • Treat crawlers and blocked browsers as the same problem. Anything that won't run your JavaScript needs the real content in the HTML already.

Underneath the specifics, this was the same shape as most of the worst bugs I find:

Human validation passed
        ↓
System validation failed
        ↓
Find the hidden assumption
        ↓
Fix the assumption, not the symptom
Enter fullscreen mode Exit fullscreen mode

The hidden assumption here was four words long — "if it works in Chrome" — and the real version was "it works in the browser I happened to develop in." The fix wasn't really a proxy. The proxy was just how I retired the assumption.

The page had been broken for months for an entire category of visitors. It looked flawless the whole time — in the one browser I happened to use.

Top comments (0)