Skip to main content

Command Palette

Search for a command to run...

How to Fix AudioContext Was Not Allowed to Start in Chrome

Published
7 min read
How to Fix AudioContext Was Not Allowed to Start in Chrome

Introduction

This is the note for future me.

This bug was not random. Chrome was correct.

We tried to start background music from mousemove. That feels like usage. It is not a real activation event for Web Audio.

So Chrome blocked AudioContext. And the console filled with this warning.

The AudioContext was not allowed to start.
It must be resumed or created after a user gesture on the page.

The Exact Problem

There were two bugs.

Bug one. We treated user activity like user activation.

mousemove and scroll can mean the user is present. They do not mean the browser will unlock protected features.

Bug two. We removed the listeners too early.

So the flow looked like this.

  1. mousemove fired first.

  2. We tried to create or resume AudioContext.

  3. Chrome blocked it.

  4. We acted like startup had happened.

  5. The next real gesture did not get a clean retry path.

That is why the warning kept coming back. That is why the fix felt slippery.

The Rule

User activity is not the same thing as user activation.

For Web Audio in Chrome. Use real activation events.

  • pointerdown

  • touchend

  • keydown

  • click

Do not rely on these for audio unlock.

  • mousemove

  • mouseenter

  • scroll

  • passive visibility changes

The Wrong Shape

This is the kind of code that caused the bug.

useEffect(() => {
  const events = ["mousemove", "scroll", "click"] as const;

  function handleInteraction() {
    startBackgroundMusic();
  }

  for (const event of events) {
    document.addEventListener(event, handleInteraction);
  }

  return () => {
    for (const event of events) {
      document.removeEventListener(event, handleInteraction);
    }
  };
}, []);

Why this is wrong.

mousemove is not enough for Chrome. startBackgroundMusic() gets called too early. If that function creates or resumes AudioContext. The browser rejects it.

The Correct Route Level Fix

In this app the route level entry point is src/routes/index/route.tsx.

The fix was to listen only for activation events. Then remove listeners only after startup actually succeeds.

useEffect(() => {
  const events = ["pointerdown", "touchend", "keydown", "click"] as const;
  let isDisposed = false;

  function removeInteractionListeners() {
    for (const event of events) {
      document.removeEventListener(event, handleInteraction);
    }
  }

  async function handleInteraction() {
    const hasStarted = await startBackgroundMusic({
      triggeredByUserGesture: true,
    });

    if (!hasStarted || isDisposed) {
      return;
    }

    removeInteractionListeners();
  }

  for (const event of events) {
    document.addEventListener(event, handleInteraction);
  }

  return () => {
    isDisposed = true;
    removeInteractionListeners();
  };
}, []);

This matters.

The route does not assume success anymore. The sound layer returns a boolean. Listeners stay alive until audio truly starts.

The Real Fix Was In The Sound Manager

This is the important part.

Fixing only the route is not enough.

Someone can call the sound code from some other component later. If the sound manager is weak. The warning comes back.

So src/lib/sound-manager.ts now enforces the browser rule itself.

Step one.

Prefetch raw files early. Do not decode yet. Do not touch AudioContext yet.

const rawBuffers = new Map<SoundName, ArrayBuffer>();

async function prefetch(): Promise<void> {
  const entries = Object.entries(SOUNDS) as Array<[SoundName, string]>;

  await Promise.all(
    entries.map(async ([name, src]) => {
      try {
        const response = await fetch(src);
        const arrayBuffer = await response.arrayBuffer();
        rawBuffers.set(name, arrayBuffer);
      } catch {
        // Fetch failed.
      }
    }),
  );
}

const prefetchPromise = prefetch();
void prefetchPromise;

Why this helps.

Network fetch is allowed before activation. Audio decode and playback are the sensitive parts.

Step two.

Check permission before creating or resuming AudioContext.

function hasPlaybackPermission({
  triggeredByUserGesture = false,
}: PlaybackOptions): boolean {
  const userActivation = navigator.userActivation;

  if (userActivation) {
    return userActivation.isActive || userActivation.hasBeenActive;
  }

  return triggeredByUserGesture;
}

Then gate AudioContext creation.

function ensureAudioContext({
  triggeredByUserGesture = false,
}: PlaybackOptions): AudioContext | null {
  if (audioContext) {
    return audioContext;
  }

  if (!hasPlaybackPermission({ triggeredByUserGesture })) {
    return null;
  }

  try {
    audioContext = new AudioContext();
    return audioContext;
  } catch {
    return null;
  }
}

This is the core idea.

Do not even try if activation is not real yet. No attempt means no Chrome warning.

Step three.

Resume only when activation is valid.

async function resumeAudioContext({
  ctx,
  triggeredByUserGesture = false,
}: PlaybackOptions & {
  ctx: AudioContext;
}): Promise<boolean> {
  if (ctx.state === "running") {
    return true;
  }

  if (ctx.state !== "suspended") {
    return false;
  }

  if (!hasPlaybackPermission({ triggeredByUserGesture })) {
    return false;
  }

  try {
    await ctx.resume();
    return true;
  } catch {
    return false;
  }
}

Step four.

Wait for fetch before decode.

This closed a loose end.

Without this. The first valid gesture can happen before the audio file is fully fetched. Then decode runs against incomplete state.

async function decodeBuffers({ ctx }: { ctx: AudioContext }): Promise<boolean> {
  if (hasDecodedBuffers) {
    return true;
  }

  if (decodeBuffersPromise) {
    return decodeBuffersPromise;
  }

  decodeBuffersPromise = (async () => {
    await prefetchPromise;

    const entries = Array.from(rawBuffers.entries());

    await Promise.all(
      entries.map(async ([name, rawBuffer]) => {
        try {
          const copy = rawBuffer.slice(0);
          const decoded = await ctx.decodeAudioData(copy);
          audioBuffers.set(name, decoded);
        } catch {
          // Decode failed.
        }
      }),
    );

    hasDecodedBuffers = true;
    return true;
  })();

  return decodeBuffersPromise;
}

Step five.

Return success from startup.

This is what lets the route behave correctly.

async function startBackgroundMusic({
  triggeredByUserGesture = false,
}: PlaybackOptions = {}): Promise<boolean> {
  if (hasBackgroundMusicStarted) {
    return true;
  }

  const ctx = await getAudioContextForPlayback({ triggeredByUserGesture });

  if (!ctx) {
    return false;
  }

  const isReady = await decodeBuffers({ ctx });

  if (!isReady) {
    return false;
  }

  const buffer = audioBuffers.get("backgroundMusic");

  if (!buffer) {
    return false;
  }

  const gainNode = ctx.createGain();
  gainNode.gain.value = 0.25;
  gainNode.connect(ctx.destination);

  const source = ctx.createBufferSource();
  source.buffer = buffer;
  source.loop = true;
  source.connect(gainNode);
  source.start(0);

  backgroundSource = source;
  hasBackgroundMusicStarted = true;
  return true;
}

Now the caller knows the truth.

Audio started. Or it did not.

That sounds obvious. It was the missing piece.

The Sound Effect Pattern

The same rule applies to short SFX.

If a click triggers a sound. Pass the gesture intent down explicitly.

void playSound({ name: "tryFreeSfx", triggeredByUserGesture: true });

That keeps the call honest. And it makes the sound manager API explicit.

Practical Checklist For Future Me

If this warning shows up again. Check these things in order.

  1. Search for new AudioContext.

  2. Search for resume().

  3. Search for every caller of startBackgroundMusic and playSound.

  4. Look for mousemove and scroll and mouseenter.

  5. Confirm the sound layer itself blocks pre activation calls.

  6. Confirm listeners are removed only after real success.

  7. Confirm file fetch can happen early but decode waits for actual activation.

Where Else This Pattern Matters

This is not only about music.

Use this mental model for any product moment where you want to react to usage.

Examples.

  • Daily streak reward panels.

  • Celebration sounds.

  • Unlock animations with sound.

  • Autoplay video attempts.

  • Active session moments.

  • Prompted coachmarks that should feel responsive.

The pattern is simple.

Layer one detects interest. That can use hover. Movement. Focus. Time on page.

Layer two fires protected browser actions. That must use real activation.

That split keeps the UI smart. And keeps the browser happy.

Short Version

We broke Chrome rules by treating mousemove as a valid audio unlock event.

The real fix was not just changing the route. The real fix was making sound-manager.ts refuse to create or resume AudioContext before valid activation.

Then we made the route wait for a real success result before removing listeners.

That killed the warning. And this is the pattern to reuse next time.