Evenings.fmEvenings Docs

Guides

How to reduce audio player latency

Updated

This guide is for stations with their own website player who want listeners closer to the live moment: call-in shows, live chat, anything where a two-second lag gets noticed. It explains where the delay comes from, then walks you through a drop-in player that removes most of it.

Where the delay comes from

Your audio passes through several hands on its way to a listener: your broadcast app (OBS, BUTT, Audio Hijack), our media server, the internet, and finally the listener's browser. Most of those steps are fast and fixed. The browser is the exception, and on a standard web player it's usually the largest single delay in the chain.

Most players, including the one in our HTML player guide, hand the stream straight to an <audio> element:

<audio src="https://media.evenings.co/s/CHANNEL_ID" controls></audio>

From there the browser decides everything, and three of its decisions work against a live station.

It buffers like it's playing a file. Browser audio was built for songs and podcasts, where reading ahead is free. A live stream can't be read ahead, because the audio doesn't exist yet, so the only way a browser can build a reserve is by playing behind live. Each browser picks its own reserve, usually one to three seconds, and there's no setting on <audio> to change it.

It never catches up. When the reserve runs dry, the browser pauses to rebuffer. Audio keeps arriving during the pause, so the listener resumes further behind, and a standard player has no way to win that time back.

It plays our instant-start burst from the beginning. So that playback starts the moment someone presses play, our media server sends each new listener the most recent 30 KB of the stream, about 1.5 seconds at the 160 kbps we stream to browsers. A standard player plays that burst from its first byte, so the listener starts 1.5 seconds behind before the browser's own buffering adds anything.

In our tests, a standard player sat about 1.8 seconds behind live from the moment it started, and rebuffered three times in its first 15 seconds.

When it matters

For a music stream someone has on in the background, a second or two makes no difference. It starts to matter once your audience is taking part:

  • Live chat. Comments land seconds after the moment they're reacting to.
  • Call-ins and guests. Anyone listening on the stream while talking to you hears a delayed echo of the conversation.
  • Watching along. If listeners can also see a video feed or are in the room, the audio trails what they see.
  • Stutters. Every rebuffer is a moment of silence, and each one pushes the listener further back.

How Media Source Extensions fix it

Media Source Extensions (MSE) is a lower-level browser API for playing media. It's what YouTube and Twitch build their players on. With MSE, your page downloads the stream itself and hands the audio to the browser piece by piece. The browser still decodes and plays it, but your page decides how much is held in reserve and where playback starts.

Standard player
  stream → browser's buffer (1–3 s, browser's choice) → speakers

MSE player
  stream → your page → small buffer (~0.3 s, your choice) → speakers

We've packaged this into player.js, a single dependency-free file in our open-source demo-mse-player repository. It reads the same stream URL you already use, so nothing changes on your station or on our servers. Here's what it does differently:

Standard <audio> playerplayer.js
Plays the instant-start burst from the start, so begins ~1.5 s behindSkips to the newest audio in the burst and starts a few tenths of a second behind live
Keeps a 1–3 s buffer you can't changeKeeps the buffer you set with targetLatency (we use 0.3 s)
Falls further behind after every rebufferPlays 5% faster, too little to hear, until it's back on target, and jumps straight to live after a long gap such as a laptop waking from sleep
Same reserve on every connectionGrows the buffer by 0.3 s each time playback runs dry, up to maxLatency
Goes quiet if the connection dropsReconnects on its own and resumes from the newest audio

Side by side for two minutes against our media server, the standard player sat about 1.8 seconds behind live with three rebuffers. The MSE player held steady at 0.43 seconds with none. We've also run the same logic for 26 hours straight (about 1.9 GB of audio) with memory staying flat and reconnects handled on their own.

The tradeoffs

  • It only removes the browser's share of the delay. Your encoder settings and the network still add theirs.
  • The buffer only grows within a session. A listener who hits a rough patch stays at the larger buffer until they press play again. On a steady connection that never comes up.
  • Reconnects can repeat a moment. A new connection starts with the instant-start burst again, so up to a second of audio may play twice before playback carries on.
  • Older browsers get the standard player. Where MSE can't play MP3, player.js falls back to a plain <audio> element, with the usual browser delay.

Try it with your station

Before building anything, open the demo with your slug in place of garage (the last part of your station's evenings.fm/ address):

https://eveningsco.github.io/demo-mse-player/?station=garage

Press Listen while you're on air. Behind live shows how many seconds the listener is behind your broadcast.

Step 01: Download the player

  1. Open player.js on GitHub and use Download raw file.
  2. Save it in the folder for the page that will play your stream.

Host player.js on your own site rather than importing it from raw.githubusercontent.com. GitHub serves files there as plain text, and browsers won't run a JavaScript module served that way.

Step 02: Add the markup

Create index.html next to player.js:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Low-latency player</title>
</head>
<body>
  <button id="listen" disabled>Loading…</button>
  <span id="latency"></span>
  <audio id="audio" hidden></audio>
 
  <script type="module">
    // JavaScript goes here
  </script>
</body>
</html>
  • #listen starts and stops playback. It stays disabled while the station is off air.
  • #latency shows how far behind live the listener is.
  • #audio is the hidden element the player drives.
  • The script needs type="module" so it can import the player.

Step 03: Add the JavaScript

Replace the comment in the <script> with the code below, and change garage to your slug:

import { LowLatencyMp3Player } from "./player.js";
 
const stationSlug = "garage"; // replace with your station's slug
const apiEndpoint = `https://api.evenings.co/v1/streams/${stationSlug}/public`;
const POLL_INTERVAL = 10000; // 10 seconds, matching the API's cache
 
const button = document.getElementById("listen");
const latency = document.getElementById("latency");
const audio = document.getElementById("audio");
 
let streamUrl = null;
let player = null;
 
// Check whether the station is on air and get its stream URL.
async function fetchStationData() {
  try {
    const res = await fetch(apiEndpoint, { cache: "no-store" });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const json = await res.json();
    streamUrl = json.online ? json.streamUrl : null;
    if (!player) {
      button.disabled = !streamUrl;
      button.textContent = streamUrl ? `Listen to ${json.host || json.name}` : "Offline";
    }
  } catch {
    if (!player) {
      button.disabled = true;
      button.textContent = "Offline";
    }
  }
}
 
fetchStationData();
setInterval(fetchStationData, POLL_INTERVAL);
 
button.addEventListener("click", () => {
  // Stop
  if (player) {
    player.stop();
    player = null;
    latency.textContent = "";
    fetchStationData();
    return;
  }
 
  // Listen
  if (!streamUrl) return;
  player = new LowLatencyMp3Player(audio, streamUrl, {
    targetLatency: 0.3, // starting buffer, in seconds
    maxLatency: 2,      // the most it will grow to on a rough connection
    onStats: (s) => {
      if (s.mode === "native") latency.textContent = "Standard playback";
      else if (s.state === "playing") latency.textContent = `${s.depth.toFixed(2)}s behind live`;
      else latency.textContent = s.state;
    },
  });
  player.start();
  button.textContent = "Stop";
});
  • fetchStationData() asks the public Streams API whether you're on air and for your stream URL. The API caches responses for up to 10 seconds, so polling faster gains nothing. It never touches the <audio> element, so a status check can't interrupt playback.
  • new LowLatencyMp3Player(...) takes over buffering for the <audio> element. Don't set audio.src yourself.
  • player.start() runs inside the click handler on purpose: browsers only allow audio with sound after the listener interacts with the page.
  • onStats fires four times a second. s.depth is the number of seconds between what the listener hears and the newest audio received.

Step 04: Preview it

Browsers won't load modules from file://, so serve the folder rather than double-clicking index.html. Any static server works. If you have Python 3 (most Linux systems include it; on macOS it comes with Apple's Command Line Tools; on Windows, install it from python.org and type py in place of python3), run this from the folder:

python3 -m http.server 8080

Open http://localhost:8080 while you're on air and press Listen. Within a second, the reading should settle at around 0.3 to 0.5 seconds behind live.

To put it on your website, upload index.html and player.js together to any static host.

Options

OptionDefaultWhat it does
targetLatency0.5Starting buffer, in seconds. Lower is closer to live; 0.3 works on most connections.
maxLatency2The largest the buffer can grow to on an unreliable connection.
onStatsnoneCalled four times a second with { state, mode, depth, playbackRate, bytesReceived, reconnects }, plus bufferedEnd and currentTime.
onStateChangenoneCalled when state changes: connecting, playing, reconnecting, stopped or fallback.

While it's playing, player.setTargetLatency(seconds) changes the target, and player.stop() stops playback and closes the connection.

Browser support

BrowserLow-latency playback
Chrome, Edge and Firefox (desktop and Android)Yes
Safari on macOSYes
Safari on iPhone and iPad (iOS 17.1 or later)Yes, through Apple's ManagedMediaSource
Older iOSFalls back to standard playback

Troubleshooting

The reading says "Standard playback."
This browser can't play MP3 through MSE, so the player fell back to a standard <audio> element. Audio still plays, with the usual browser delay.

It stutters a few times right after starting, then settles.
That's the player learning the connection: each stall adds 0.3 seconds to the buffer. If many of your listeners are on mobile data or patchy Wi-Fi, start with targetLatency: 1 to skip most of that.

Nothing happens when you press Listen.
Check that the page is served over http:// or https:// rather than opened as a file, and that player.js sits in the same folder as index.html. Your browser's developer console will show the exact error.

A second of audio repeated.
The listener's connection dropped and the player reconnected. The new connection starts with the instant-start burst, so a moment can play twice before playback carries on.

Next steps

The Streams API reference has every field the player reads. If you try player.js with your station, we'd like to hear how far behind live your listeners end up, and if anything doesn't behave as described here, email contact@evenings.email and we'll work through it with you.