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, initiatormedia Accept: audio/webm,audio/ogg,audio/wav,audio/*Sec-Fetch-Dest: audioRange: bytes=0-0 Bback
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.
""againsthttps://app.example.com/#...becomeshttps://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-Destpointed straight at the cause, and I only looked there after everything else. - Reproduce in the browser that actually fails. Empty-
srcbehaviour 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”.