A soundboard looks like the simplest app ever: a grid of buttons, each one plays a sound. I thought I'd finish one in an evening.
Then I tested it on my phone. Tap a button, wait about 300 ms, then the sound. On iOS, the first tap sometimes did nothing at all. Spam a button quickly and sounds either cut each other off or queued up like a slow printer.
For a meme soundboard, timing is the whole joke. A late "bruh" isn't funny. This post covers what I changed to make playback feel instant, using Next.js on Vercel, React and the Web Audio API.
Why new Audio() feels slow
The first version looked like this:
function SoundButton({ src, label }: { src: string; label: string }) {
return <button onClick={() => new Audio(src).play()}>{label}</button>;
}
It works, but every tap:
- Creates a new media element.
- Starts a network request (or a cache lookup).
- Decodes the audio progressively.
- Starts playback once enough data is buffered.
HTMLAudioElement is built for streaming long media, not firing short clips on demand. Even with the file cached, mobile browsers add noticeable startup latency. Reusing one element per sound helps a bit, but then you can't overlap the same sound, and calling currentTime = 0 on a playing element has its own delays.
Switching to the Web Audio API
The Web Audio API lets you download and decode audio ahead of time into an AudioBuffer, which lives in memory as raw PCM. Playing a buffer is just scheduling it on the audio graph, which is close to instant.
Here's the small engine I ended up with:
// lib/audio-engine.ts
let ctx: AudioContext | null = null;
const buffers = new Map<string, AudioBuffer>();
const inflight = new Map<string, Promise<AudioBuffer>>();
export function getContext(): AudioContext {
if (!ctx) {
const Ctor = window.AudioContext ?? (window as any).webkitAudioContext;
ctx = new Ctor({ latencyHint: "interactive" });
}
return ctx;
}
export function loadSound(url: string): Promise<AudioBuffer> {
const cached = buffers.get(url);
if (cached) return Promise.resolve(cached);
const existing = inflight.get(url);
if (existing) return existing;
const p = fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`Failed to load ${url}`);
return res.arrayBuffer();
})
.then((data) => getContext().decodeAudioData(data))
.then((buffer) => {
buffers.set(url, buffer);
inflight.delete(url);
return buffer;
});
inflight.set(url, p);
return p;
}
export function playSound(url: string, volume = 1) {
const context = getContext();
const buffer = buffers.get(url);
if (!buffer) {
// Not ready yet: load it, then play. The first tap is slower, the rest are instant.
loadSound(url).then(() => playSound(url, volume));
return;
}
const source = context.createBufferSource();
source.buffer = buffer;
const gain = context.createGain();
gain.gain.value = volume;
source.connect(gain).connect(context.destination);
source.start(0);
return source;
}
A few things worth pointing out:
-
latencyHint: "interactive"asks the browser for the smallest output buffer it can manage. -
inflightmap dedupes requests, so hovering over five buttons quickly doesn't fire five fetches for the same file. -
Each play creates a new
AudioBufferSourceNode. They're cheap and single-use by design, which is exactly what lets the same sound overlap with itself.
Preloading without killing mobile data
Decoded audio is big. A 3-second stereo clip at 44.1 kHz is roughly 1 MB of PCM in memory, compared to ~50 KB as an MP3. Preloading 200 sounds on page load is a bad idea on a phone.
My compromise:
- Preload the first screen of buttons once the page is idle.
- Preload others on
pointerenter/focus, which on desktop gives you a 100-200 ms head start before the click. - Preload more as the user scrolls, using an
IntersectionObserver.
"use client";
import { useEffect, useRef } from "react";
import { loadSound, playSound } from "@/lib/audio-engine";
export function SoundButton({ src, label }: { src: string; label: string }) {
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
loadSound(src).catch(() => {});
io.disconnect();
}
},
{ rootMargin: "200px" }
);
io.observe(el);
return () => io.disconnect();
}, [src]);
return (
<button
ref={ref}
onPointerEnter={() => loadSound(src).catch(() => {})}
onPointerDown={() => playSound(src)}
>
{label}
</button>
);
}
Notice onPointerDown instead of onClick. A click fires on release, which adds the whole length of the tap (often 80-150 ms) before anything happens. Playing on press makes it feel like a physical button.
Mobile autoplay: the gotcha that eats your first tap
Browsers don't let pages make sound until the user interacts. With Web Audio, that means your AudioContext starts in the "suspended" state and has to be resumed inside a user gesture handler.
iOS Safari is the strictest. If you create the context during page load, it stays suspended, and the first tap that just calls source.start() plays nothing.
The fix is to resume (or create) the context on the first interaction:
// lib/unlock-audio.ts
import { getContext } from "./audio-engine";
export function installAudioUnlock() {
const unlock = () => {
const ctx = getContext();
if (ctx.state === "suspended") ctx.resume();
// Old iOS trick: play a 1-sample silent buffer to fully "warm up" output
const buffer = ctx.createBuffer(1, 1, 22050);
const src = ctx.createBufferSource();
src.buffer = buffer;
src.connect(ctx.destination);
src.start(0);
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
};
window.addEventListener("pointerdown", unlock, { once: false });
window.addEventListener("keydown", unlock, { once: false });
}
Call it once from a client component in your layout. Two more iOS things that cost me time:
-
The ring/silent switch. On iPhones, Web Audio is muted by the hardware silent switch, while a
<video>with audio isn't. Users will report "no sound" and it's their switch. I added a small "no sound? check silent mode" hint. -
Context gets interrupted. Taking a phone call or switching apps can put the context into
"interrupted"or"suspended". Checkctx.statebefore each play and callresume()if needed.
Encoding and hosting on Vercel
Some practical notes on the files themselves:
- Trim the silence. Many meme clips have 200-400 ms of silence at the start. No amount of code fixes that. I batch-trim with ffmpeg:
ffmpeg -i input.mp3 -af "silenceremove=start_periods=1:start_threshold=-50dB" \
-ac 1 -b:a 96k output.mp3
- Mono, 96 kbps MP3 or AAC is plenty for phone speakers and halves the download size. Every browser supports MP3, so you don't need fallbacks.
-
Long cache headers. Put files in
/public/soundswith hashed or versioned names, and set immutable caching innext.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
async headers() {
return [
{
source: "/sounds/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
];
},
};
export default config;
Vercel's CDN serves these from the edge, so after the first visit they come from the browser cache and the only cost left is decoding.
Handling spam-tapping
People will hammer the airhorn button 30 times a second. Overlapping 30 copies distorts badly and can make cheap phones stutter. I cap concurrent voices per sound:
const active = new Map<string, AudioBufferSourceNode[]>();
const MAX_VOICES = 4;
export function playLimited(url: string) {
const list = active.get(url) ?? [];
if (list.length >= MAX_VOICES) list.shift()?.stop();
const src = playSound(url);
if (!src) return;
list.push(src);
active.set(url, list);
src.onended = () => active.set(url, (active.get(url) ?? []).filter((s) => s !== src));
}
Adding a "stop all" button that disconnects everything is also a kindness to everyone else in the room.
The result
After these changes, a tap on a preloaded button on my mid-range Android phone plays in what feels like zero delay. On iOS, the first tap now works reliably. This is the setup running behind MemeTap, the meme soundboard I built with Next.js, and it's held up well on real devices.
To recap:
- Use the Web Audio API with decoded
AudioBuffers instead ofnew Audio(). - Preload smartly: what's visible, what's hovered, what's about to scroll into view.
- Play on
pointerdown, notclick. - Resume the
AudioContextinside a user gesture, and expect iOS to be special. - Trim, compress and cache the files themselves.
If you've found other tricks for low-latency audio on the web, especially on older Android devices, I'd love to hear them in the comments.
Top comments (0)