A soundboard lives or dies on the first few milliseconds after a tap. If the clip starts late, the joke lands late. If it starts on the second tap, people think the first one did nothing. If it starts twice, they think the page is broken.

I run into this on soundboard buttons more often than I would like to admit. The clip is short, the page is a grid of sound buttons, and the person using it is not waiting politely. They tap, they expect sound, and they tap again if nothing happens. This post is the small event-and-audio plan I now use so the sound starts in the same turn as the gesture.

The code below is plain JavaScript. The helpers are tested with node:test in Node 20+. The browser-specific bits — AudioContext.resume() and element.play() — are written as injected functions so the order can be tested without a window.

What "the same turn" means

A user gesture — a pointer down, a tap, a click, a key press — arrives as an event. While that event handler is still running, the browser treats your code as having permission to start audio. The moment you await something, you have left that turn. The resume() promise can settle later in a microtask. If you wait for it before you call play(), some mobile browsers will refuse the play, or will start it only after a second tap.

That is the whole bug in one sentence: do not await the resume before you start the sound.

You can still handle the promise. You just must not put play() after it.

Why a click listener feels late

click is the event everyone learns first, and it works on a desktop. On a phone it is often the last event in a chain, and sometimes it never fires.

A typical sequence for a finger tap looks like this:

  1. pointerdown (and, on some browsers, touchstart)
  2. pointerup / touchend
  3. click, a little later, or not at all if the finger moved enough to count as a scroll

If your only listener is click, two things go wrong. The sound starts after the delay. Or the tap was also a slight scroll, click is cancelled, and nothing plays. The person taps again. Now you may get a play from the second attempt, or two plays if the first click arrives late.

The fix is to listen to an earlier event and ignore the later click for the same pointer.

pointerdown is the best default. It fires for mouse, pen and touch, and it fires at the start of the gesture. touchend is a reasonable extra if you still have to support a browser without pointer events. Keep click as a fallback for keyboard-activated buttons and older browsers, but do not let it fire in addition to a pointer handler that already started the sound.

export const GESTURE_EVENTS = new Set([ "pointerdown", "pointerup", "touchstart", "touchend", "mousedown", "mouseup", "click", "keydown", ]); export function isTrustedGesture(type) { return GESTURE_EVENTS.has(type); } /** * Prefer an earlier pointer or touch event over click. * On some mobile browsers, click is delayed or skipped after a scroll-as-tap. */ export function preferredEventOrder() { return ["pointerdown", "touchend", "mousedown", "click"]; } 

A small plan function

The next helper answers two questions for each event: is this a gesture at all, and does the audio context need to be resumed before playback?

export function playPlan({ eventType, contextState }) { if (!isTrustedGesture(eventType)) { return { ok: false, resumeFirst: false, playNow: false, reason: "not-a-gesture" }; } return { ok: true, resumeFirst: contextState !== "running", playNow: true, reason: eventType === "click" ? "click-may-be-late" : "ok", }; } /** * Resume the context and start playback in the same synchronous turn. * Do not await the resume promise before calling play(). */ 

A few details are worth pointing out.

  • pointermove, scroll, load and ended are not gestures. If a sound starts from one of those, it is usually a leftover play() from an earlier tap, or a bug. The plan refuses them.
  • A suspended context must be resumed. Browsers create AudioContext in a suspended state until a gesture resumes it. If you only call source.start() on a suspended context, you hear nothing.
  • click is allowed, but flagged. The flag is there so a wrapper can ignore a click that follows a pointerdown it already handled.

Resume and play in one turn

Here is the function that makes the order explicit. Both resumeFn and playFn are injected, so the test can pass a fake AudioContext.resume and a fake HTMLAudioElement.play.

export function sameTurnResumeAndPlay(resumeFn, playFn) { const order = []; const resumeResult = resumeFn(); order.push("resume"); const playResult = playFn(); order.push("play"); return { order, resumeResult, playResult }; } 

The important test is this one: resumeFn returns a Promise that has not settled yet, and playFn still runs before that Promise resolves. If someone later "cleans up" the code by writing await context.resume(); audio.play();, the test fails.

In a real page the wiring looks like this:

function onPointerDown(event, context, audio) { const plan = playPlan({ eventType: event.type, contextState: context.state, }); if (!plan.ok) return; armElement(audio); const resumeFn = plan.resumeFirst ? () => context.resume() : () => undefined; const playFn = () => audio.play(); sameTurnResumeAndPlay(resumeFn, playFn); } 

armElement pauses the element and seeks to the start so a second tap retriggers the same clip instead of doing nothing because the first play is still at the end, or worse, overlapping a second copy from the current position.

export function armElement(el) { try { el.pause(); } catch {} try { el.currentTime = 0; } catch {} return el; } 

If you are playing AudioBufferSourceNodes instead of an <audio> element, the same rule applies: context.resume() and source.start(0) must both be called from the handler, not from an await after it. Buffer sources can only start once, so you create a new source for each tap, point it at the same buffer, and start it immediately.

Ignoring the leftover click

Once pointerdown starts the sound, the later click should not start it again. A small flag, or a record of the pointer id, is enough:

let handledByPointer = false; button.addEventListener("pointerdown", (event) => { handledByPointer = true; onPointerDown(event, context, audio); }); button.addEventListener("click", (event) => { if (handledByPointer) { handledByPointer = false; return; } onPointerDown(event, context, audio); }); 

Reset the flag at the end of the click handler, not on a timer. A timer will race with a fast second tap.

Keyboard users still get a click when they press Enter or Space on a focused button, and handledByPointer will be false in that case, so the fallback remains useful.

What not to do

A few patterns look tidy and fail in the ways described above.

  • await context.resume(); await audio.play(); inside the handler. The first await leaves the gesture turn.
  • Starting the sound in a setTimeout(..., 0) to "let the UI update first". The timeout is not the gesture.
  • Calling play() from the canplaythrough or loadeddata handler after a tap that only called load(). The load event is not a gesture. Decode or load ahead of time, then call play() from the tap.
  • Relying on a hidden autoplaying silent <audio> to "warm up" the page. Autoplay policy is inconsistent, and a silent element can still surprise someone on a public speaker. Resume the context from the first real tap instead.

I am not claiming a single number for how late a click can be. It depends on the browser, the device, and whether the finger moved. The safer design is to start on the earlier event and treat click as backup.

Tests

The helpers above live in play-on-gesture.mjs. The tests run in Node and do not need a browser:

import { test } from "node:test"; import assert from "node:assert/strict"; import { isTrustedGesture, preferredEventOrder, playPlan, sameTurnResumeAndPlay, armElement, } from "./play-on-gesture.mjs"; test("only real user-gesture event types count", () => { for (const t of ["pointerdown", "touchend", "click", "keydown", "mousedown"]) { assert.equal(isTrustedGesture(t), true, t); } for (const t of ["pointermove", "scroll", "load", "ended", "timeupdate"]) { assert.equal(isTrustedGesture(t), false, t); } }); test("pointerdown is preferred ahead of click", () => { const order = preferredEventOrder(); assert.ok(order.indexOf("pointerdown") < order.indexOf("click")); assert.ok(order.indexOf("touchend") < order.indexOf("click")); }); test("a non-gesture must not start audio", () => { const plan = playPlan({ eventType: "pointermove", contextState: "running" }); assert.equal(plan.ok, false); assert.equal(plan.playNow, false); assert.equal(plan.reason, "not-a-gesture"); }); test("a suspended context is resumed, then play is requested, in the same plan", () => { const plan = playPlan({ eventType: "pointerdown", contextState: "suspended" }); assert.equal(plan.ok, true); assert.equal(plan.resumeFirst, true); assert.equal(plan.playNow, true); }); test("a running context still plays, without a resume step being required", () => { const plan = playPlan({ eventType: "pointerdown", contextState: "running" }); assert.equal(plan.resumeFirst, false); assert.equal(plan.playNow, true); }); test("click is allowed but flagged as possibly late", () => { const plan = playPlan({ eventType: "click", contextState: "running" }); assert.equal(plan.ok, true); assert.equal(plan.reason, "click-may-be-late"); }); test("resume and play run in the same turn, even if resume returns a Promise", () => { let playCalled = false; let resumePending = true; const resumeFn = () => { const p = new Promise((resolve) => queueMicrotask(() => { resumePending = false; resolve(); })); return p; }; const playFn = () => { // Must run before the resume promise settles. assert.equal(resumePending, true, "play() ran after await, which breaks the gesture"); playCalled = true; return Promise.resolve("playing"); }; const result = sameTurnResumeAndPlay(resumeFn, playFn); assert.deepEqual(result.order, ["resume", "play"]); assert.equal(playCalled, true); assert.equal(result.resumeResult instanceof Promise, true); }); test("armElement pauses and seeks to the start so a second tap retriggers", () => { const el = { paused: false, currentTime: 1.25, pause() { this.paused = true; } }; armElement(el); assert.equal(el.paused, true); assert.equal(el.currentTime, 0); }); 

Output from node --test on this box, Node 20:

✔ only real user-gesture event types count (0.803595ms) ✔ pointerdown is preferred ahead of click (0.165777ms) ✔ a non-gesture must not start audio (0.863093ms) ✔ a suspended context is resumed, then play is requested, in the same plan (0.132846ms) ✔ a running context still plays, without a resume step being required (0.105637ms) ✔ click is allowed but flagged as possibly late (0.103963ms) ✔ resume and play run in the same turn, even if resume returns a Promise (0.794314ms) ✔ armElement pauses and seeks to the start so a second tap retriggers (0.211918ms) ℹ tests 8 ℹ suites 0 ℹ pass 8 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 58.481559 

Eight tests, all passing. The test that would fail first if someone awaited the resume is "resume and play run in the same turn, even if resume returns a Promise".

Putting it on a soundboard

A page of buttons does not need a more complicated state machine than this. For each button:

  1. Decode or preload the clip when the page is idle, or on hover for desktop, so a tap is not also a network wait.
  2. Listen to pointerdown, with click as a fallback, and ignore the leftover click.
  3. Resume the shared AudioContext if it is not running, and start the clip in the same turn.
  4. If the same button is tapped again, stop the current source or element and start a fresh one from the beginning, unless you have a reason to let it overlap.

That is the behaviour people expect from physical soundboard buttons, and it is the behaviour that makes a grid of sound buttons feel immediate. The audio work — trimming, loudness, format — can be perfect, and the board will still feel broken if the first tap is swallowed by a late click or an awaited resume().

The helpers in this post are small on purpose. They do not draw the grid, and they do not decode files. They only make the promise you are making to the person with their finger on the button: when you tap, the sound starts now.