vohzd.com

Why Firefox Hangs When You Leave a Page With Audio

5 min read

The problem

I built a fake numbers station for my site: shortwave radio aesthetic, synthesised voices reading numbers, oscilloscope visualisations, the works. It uses the Web Audio API for noise generation and speechSynthesis for the voice. Worked beautifully, until I tried to navigate away from it on Firefox.

Five to ten seconds of nothing. The whole page just freezes. Click a link, wait, stare at the screen, eventually the browser catches up and transitions. On Chromium? Instant. Firefox on Linux? Pain. For reference, I'm running Ubuntu 24.04 (Noble Numbat) with PipeWire providing PulseAudio compatibility, which is the default setup these days.

I tried everything. Deferring AudioContext.close() with setTimeout. Deferring speechSynthesis.cancel(). Skipping both entirely and just nulling references. Zeroing out gain nodes and letting garbage collection deal with the rest. Nothing helped, the hang persisted.

So I did what any reasonable person would do and went digging through Firefox's C++ source code.

The two smoking guns

There are two independent blocking paths that both converge on the same outcome: the main thread gets held hostage by Linux audio IPC.

1. AudioContext teardown blocks the main thread

When you close an AudioContext, the call chain eventually lands in AudioCallbackDriver::Shutdown() in dom/media/GraphDriver.cpp. Here's the critical bit:

void AudioCallbackDriver::Shutdown() {
  MOZ_ASSERT(NS_IsMainThread());
  // ...
  NS_DispatchAndSpinEventLoopUntilComplete(
      reason, mCubebOperationThread,
      NS_NewRunnableFunction(reason.get(),
                             [self = RefPtr{this}] { self->Stop(); }));
}

NS_DispatchAndSpinEventLoopUntilComplete. Read that name carefully. It dispatches Stop() to a background thread, then spins the main thread's event loop until it finishes. The main thread is blocked. Your UI is frozen. Nothing renders, nothing transitions, no click events fire.

The Stop() call goes through cubeb (Firefox's cross-platform audio abstraction) down to the system audio backend. On Linux, that's either ALSA or PulseAudio.

The ALSA path

In media/libcubeb/src/cubeb_alsa.c, alsa_stream_stop() contains this:

pthread_mutex_lock(&ctx->mutex);
while (stm->state == PROCESSING) {
    r = pthread_cond_wait(&stm->cond, &ctx->mutex);
}

A condition variable wait loop. It blocks until the audio processing thread finishes its current cycle. If that thread is mid-callback or waiting on hardware, you wait.

Then alsa_stream_destroy() can call snd_pcm_drain(), which blocks until the hardware has played out remaining samples.

The PulseAudio path

The PulseAudio backend (cubeb_pulse.c) does something similar. pulse_stream_stop() locks the PulseAudio threaded mainloop, waits for any in-progress drain to finish, then calls stream_cork() which issues a pa_stream_cork() operation and calls operation_wait():

while (pa_operation_get_state(o) == PA_OPERATION_RUNNING) {
    pa_threaded_mainloop_wait(ctx->mainloop);
}

Another blocking wait, this time on PulseAudio's IPC. If PipeWire is running as a PulseAudio compatibility layer (which is the default on most modern distros now), you're waiting on PipeWire's response through that shim. Extra latency.

2. speechSynthesis.cancel() does synchronous socket I/O

The speech synthesis path is arguably worse. When you call speechSynthesis.cancel(), it goes through IPC from the content process to the parent process, where it ends up in SpeechDispatcherService.cpp:

NS_IMETHODIMP
SpeechDispatcherCallback::OnCancel() {
  if (spd_cancel(mService->mSpeechdClient) < 0) {
    return NS_ERROR_FAILURE;
  }
  return NS_OK;
}

spd_cancel() is a call into libspeechd, the speech-dispatcher client library. This does synchronous socket I/O to the speech-dispatcher daemon. No timeout. No async option. The function blocks until the daemon acknowledges the cancellation over a Unix socket.

Firefox's own source code acknowledges the problem. From the init function:

// While speech dispatcher has a "threaded" mode, only spd_say() is async.
// Since synchronous socket i/o could impact startup time, we do
// initialization in a separate thread.

They moved the init off-thread because of the synchronous I/O. But spd_cancel() still runs on the parent process main thread, every single time.

If speech-dispatcher is slow to respond (busy with another client, espeak-ng is mid-phoneme, the daemon is under load), you wait. Five seconds, ten seconds, however long it takes.

Why deferred cleanup doesn't help

My first instinct was to wrap everything in setTimeout(() => ..., 0). Defer the blocking calls until after the navigation guard returns. The problem is that Firefox still runs these callbacks on the main thread, just one tick later, right in the middle of the page transition. The navigation has "started" but the new page can't render because the main thread is stuck in a pthread_cond_wait or a spd_cancel().

Even nulling all references and skipping cleanup entirely didn't work. Firefox's garbage collector eventually needs to tear down the orphaned AudioContext, and when it does, it hits the same blocking path. You've just moved the freeze from "during navigation" to "slightly after navigation", and often it's not even perceptibly different.

The workaround

The fix I landed on is blunt but effective: don't do an SPA navigation at all.

onBeforeRouteLeave((to) => {
  if (isConnected.value) {
    window.location.href = to.fullPath;
    return false;
  }
});

When audio has been used, cancel the Vue Router navigation and do a full page load instead. The browser kills the old page's process entirely, so audio resources get cleaned up natively by the OS, off the JavaScript thread. No freeze, no hang, instant navigation. When audio was never enabled, normal client-side routing works as usual.

It's not elegant. It means one extra page load for users who had audio turned on. But compared to a ten-second freeze, I'll take it.

What would actually fix this

The real fix would need to happen inside Firefox. Two things:

  1. Don't spin the main thread event loop in AudioCallbackDriver::Shutdown(). The cubeb stop/destroy should complete asynchronously and resolve the close() promise when it's done, without blocking the main thread.

  2. Move spd_cancel() off the main thread. Firefox already does this for spd_open() during initialisation, and the same pattern could apply to cancellation.

Both of these are architectural decisions deep in Firefox's media stack. They've been this way for a long time, and they work fine on systems where the audio IPC is fast (macOS, Windows). On Linux, where you're going through PulseAudio, PipeWire, ALSA, or speech-dispatcher (all with their own IPC overhead) it falls apart.

For now, window.location.href it is.