A web app I was working on had a bug that only showed up in Firefox. Start a voice recording and the browser would hammer /, its own document root, with thousands of requests, roughly 100 a second, for as long as the recording ran. Chrome and Safari didn’t care. It came down to one empty string.

The symptom

The app has a voice-to-notes feature. You hit record, a live waveform draws from the mic, you stop, and it transcribes. None of that should be hitting the network in a loop.

Firefox’s Network panel was wall-to-wall identical requests though, all GET /, and every one had the same shape.

  • Type media, initiator media
  • Accept: audio/webm,audio/ogg,audio/wav,audio/*
  • Sec-Fetch-Dest: audio
  • Range: bytes=0-
  • 0 B back

So something was treating the document root as an audio file and re-fetching it ~100 times a second. Only in Firefox.

The false trail

My first guess was the audio playback component. A recent change had started passing pre-computed waveform peaks to wavesurfer, and I could see how a record with a blank download URL might push it into loading an empty URL. It seemed like a reasonable culprit, so I fixed it. That did nothing, so I found another angle on the same idea and fixed that too, and it still didn’t help.

None of it worked because the playback component doesn’t even render while you’re recording. I’d been reasoning from the library source and my own mental model of the code, when I should have been watching what the app was actually doing in the browser while the requests piled up.

Getting real evidence

I went back and read the request headers properly. A Sec-Fetch-Dest of audio, an initiator of media, a URL of /. That’s an <audio> element with an empty src, and an empty src resolves to the page’s own URL.

That narrowed it down to whatever kept setting an audio element’s src to an empty string while I recorded, which turned out to be the live waveform.

The real culprit

The live waveform is drawn by wavesurfer’s record plugin. To animate the mic trace it runs an interval, and every ~10ms it calls this.

this.wavesurfer.load("", [this.dataWindow], /* duration */)

The channel data lets it skip fetching audio, but load("") still calls setSrc("") under the hood, which runs this.

this.media.src = ""

So media.src = "" fires ~100 times a second for the length of the recording.

Why only Firefox

Assigning media.src = "" kicks off the HTML media element load algorithm. The spec says an empty src should fail without fetching. Browsers disagree on when they check for emptiness relative to resolving the URL.

  • Firefox resolves the empty string against the document base URL first. "" against https://app.example.com/#... becomes https://app.example.com/, which isn’t empty, so it fetches that as media.
  • Chrome and Safari bail on the empty value before resolving, and never hit the network.

The same line of code does nothing in Chrome and floods the network in Firefox. I’d been testing in Chrome, so I never saw it.

preload="none" doesn’t help either. It only defers automatic loading. Explicitly assigning src still triggers the load algorithm, and Firefox’s resolve-then-check order fires regardless.

The fix

The load("") call lives in a third-party plugin, so I couldn’t just delete it. Instead I changed what media.src = "" does on the element I pass to wavesurfer.

src is a native accessor on HTMLMediaElement.prototype, so an own property on the instance shadows it.

const media = document.createElement("audio");
media.preload = "none";

Object.defineProperty(media, "src", {
  configurable: true,
  get(): string {
    return media.getAttribute("src") ?? "";
  },
  set(value: string) {
    if (value) {
      media.setAttribute("src", value);
    } else {
      media.removeAttribute("src");
    }
  },
});

Now an empty assignment calls removeAttribute instead of the native setter. That matters because setAttribute and removeAttribute don’t trigger the load algorithm, while assigning .src or calling .load() does. Firefox never gets an empty src to resolve, so it never fetches /. A real URL still goes through setAttribute, so playback keeps working, and the recording waveform comes from the mic stream anyway, so it never needed a real source.

Pass that element to wavesurfer as its media option and the empty assignments do nothing.

Takeaway

  • Read the Network panel early. The initiator and Sec-Fetch-Dest pointed straight at the cause, and I only looked there after everything else.
  • Reproduce in the browser that actually fails. Empty-src behaviour differs between engines.
  • Check that the component you’re fixing is even on screen when the bug fires. I burned two fixes skipping that.
  • In Firefox, element.src = "" means “fetch this page again, as audio”.