DEV Community

Abid Ali
Abid Ali

Posted on

Building a Low-Latency Soundboard in Next.js: Audio Preloading, Web Audio and Mobile Autoplay Gotchas

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>;
}
Enter fullscreen mode Exit fullscreen mode

It works, but every tap:

  1. Creates a new media element.
  2. Starts a network request (or a cache lookup).
  3. Decodes the audio progressively.
  4. 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;
}
Enter fullscreen mode Exit fullscreen mode

A few things worth pointing out:

  • latencyHint: "interactive" asks the browser for the smallest output buffer it can manage.
  • inflight map 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:

  1. Preload the first screen of buttons once the page is idle.
  2. Preload others on pointerenter / focus, which on desktop gives you a 100-200 ms head start before the click.
  3. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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". Check ctx.state before each play and call resume() 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
Enter fullscreen mode Exit fullscreen mode
  • 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/sounds with hashed or versioned names, and set immutable caching in next.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;
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Use the Web Audio API with decoded AudioBuffers instead of new Audio().
  2. Preload smartly: what's visible, what's hovered, what's about to scroll into view.
  3. Play on pointerdown, not click.
  4. Resume the AudioContext inside a user gesture, and expect iOS to be special.
  5. 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)