iKit
Tutorial · 10 min read ·

Web Audio API Beep: Build a Browser Alarm Sound (2026)

A Web Audio API beep needs no audio file. One oscillator, one gain node and five lines of JavaScript give any timer an alarm that never fails to load.

Web Audio API Beep: Build a Browser Alarm Sound (2026)

Web Audio API Beep: A Browser Alarm Without Audio Files

Your timer hits zero and nothing happens, because the MP3 you hosted returned a 404 on someone's flaky connection. A Web Audio API beep removes that failure mode entirely: the browser synthesises the tone from a frequency number, so there is no file to fetch, cache, or lose. Five lines produce a working alarm. Another ten make it sound like something a designer approved.

TL;DR

  • An OscillatorNode plus ctx.destination is a working beep in five lines.
  • No audio file means no 404, no CDN cost, and no decode delay.
  • Route through a GainNode and ramp the gain to kill the on/off click.
  • Autoplay rules mean the context starts suspended until a user gesture.
  • Schedule repeats against ctx.currentTime, never setTimeout.

How do I make a beep sound in JavaScript without an audio file?

You describe the sound instead of downloading it. The Web Audio API models audio as a graph of nodes: a source produces a signal, optional nodes shape it, and the destination sends it to the speakers. For a beep, the source is an oscillator — a node that emits a continuous periodic waveform at a frequency you choose.

The five lines that make a beep

const ctx = new AudioContext();
const osc = new OscillatorNode(ctx, { frequency: 880 });
osc.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.2);

That plays a 200 ms tone at 880 Hz. AudioContext is the graph and its clock. OscillatorNode is the source. connect() wires the source to the output. start() and stop() are inherited from AudioScheduledSourceNode, and both take a time on the context's own clock rather than a wall-clock delay.

What each node in the chain does

The chain here is deliberately minimal — source straight to destination:

  • AudioContext — owns the audio clock (ctx.currentTime, in seconds) and the output device.
  • OscillatorNode — generates the waveform. Per MDN, its frequency defaults to 440 Hz and its type defaults to "sine".
  • ctx.destination — the speakers. Anything connected here is audible.

An oscillator is single-use. Once you call stop(), that node is spent; the next beep needs a new OscillatorNode. This surprises people who expect to reuse it like an <audio> element, but node creation is cheap — it is a few object allocations, not a decode.

Why 880 Hz reads as an alarm

880 Hz is A5, one octave above the 440 Hz concert-A default. Frequencies roughly between 800 Hz and 2 kHz sit where human hearing is most sensitive and where small laptop and phone speakers actually reproduce sound well. A 200 Hz beep on a phone speaker is mostly silence. This is why kitchen timers, microwaves and smoke alarms all cluster in the same high, thin register — it is the range that survives bad hardware and background noise.

Why does my beep click at the start and end?

Because the raw oscillator jumps from silence to full amplitude in a single sample. That vertical edge in the waveform is broadband energy, and your ear hears it as a click on top of the tone. The oscillator is not broken; it is doing exactly what you asked.

The click is the envelope, not the oscillator

Fixing it means shaping amplitude over time — an envelope. Insert a GainNode between the oscillator and the destination, start the gain near zero, ramp it up over about 10 ms, then ramp it back down before the oscillator stops. Ten milliseconds is short enough to feel instant and long enough to remove the edge.

Fading out with exponentialRampToValueAtTime

Exponential ramps match how loudness is perceived, so a fade-out sounds smooth rather than sounding like it collapses at the end. There is one trap worth knowing before you write it: MDN's exponentialRampToValueAtTime() reference notes that the target value must be positive — passing 0 throws. Ramp to 0.0001 instead, which is inaudible.

A reusable beep() with attack and release

function beep(ctx, freq = 880, ms = 200, when = 0) {
  const t = when || ctx.currentTime;
  const osc = new OscillatorNode(ctx, { frequency: freq });
  const gain = new GainNode(ctx);
  osc.connect(gain).connect(ctx.destination);

  const end = t + ms / 1000;
  gain.gain.setValueAtTime(0.0001, t);
  gain.gain.exponentialRampToValueAtTime(0.3, t + 0.01);
  gain.gain.exponentialRampToValueAtTime(0.0001, end);

  osc.start(t);
  osc.stop(end + 0.02);
}

Two details carry the quality here. The peak gain is 0.3, not 1.0 — a full-scale square wave through a laptop speaker is genuinely painful, and headroom leaves room for a second beep to overlap without clipping. And the oscillator stops 20 ms after the gain reaches zero, so the release is never cut short.

Note also that scheduling methods and the .value property are not interchangeable. MDN's Web Audio API best practices guide is explicit that once you use an AudioParam method such as setValueAtTime(), it takes precedence over a plain gain.gain.value = 0.5 assignment, even if that assignment appears later in the file. Pick one style per parameter and stay with it.

Why won't my AudioContext play sound until the user clicks?

Because autoplay policies exist, and they cover synthesised audio just as much as media files. Silent-by-default is the correct behaviour for a page you have not interacted with yet — but it means your alarm can fail on the one moment it matters.

suspended vs running AudioContext state

Every context carries a state of suspended, running or closed. A context constructed on page load, before any gesture, is created suspended and stays that way. MDN's best-practices guide reduces the whole policy to one line:

"Create or resume context from inside a user gesture"

— MDN

Unlocking the context on the first user gesture

The reliable pattern is lazy creation behind whatever button the user already presses to start the timer:

let ctx;

function getAudioContext() {
  ctx ??= new AudioContext();
  if (ctx.state === "suspended") ctx.resume();
  return ctx;
}

startButton.addEventListener("click", () => {
  beep(getAudioContext(), 880, 60);
});

The short confirmation beep on start is not decoration. It unlocks the context and simultaneously proves to the user that sound works — before they walk away and trust the alarm.

What the policy actually blocks

Chrome's autoplay policy documentation records that the Web Audio API came under the policy in Chrome 71, after media elements were covered in Chrome 66. The same page notes that a suspended context will also resume after a user gesture if start() is called on an attached node — but relying on that side effect is fragile. Check state and call resume() explicitly.

One more practical consequence: resume() returns a promise, and a rejected one usually means the gesture requirement was not satisfied. If your handler is async and you await something before touching audio, the browser may no longer treat you as being inside the gesture.

How to schedule a repeating alarm pattern

A single beep says "done". Three rising beeps say "done, and I mean it." The instinct is to reach for setTimeout between beeps — resist it, because that puts the pattern on the JavaScript timer queue, which is exactly the clock that drifts and gets throttled.

Schedule against currentTime, not setTimeout

The audio clock runs on the audio thread and is not affected by main-thread jank. Every start(), stop() and ramp accepts an absolute time on that clock, so you can queue the entire pattern in one synchronous pass:

function alarmPattern(ctx, count = 3, gapMs = 350) {
  const start = ctx.currentTime + 0.05;
  for (let i = 0; i < count; i++) {
    beep(ctx, 880, 180, start + (i * gapMs) / 1000);
  }
}

The 50 ms lead-in matters. Scheduling at exactly currentTime asks for audio that should already have been rendered, and browsers handle that case by starting immediately — which can clip the attack.

Choosing a waveform

OscillatorNode.type accepts four built-in shapes, and swapping between them is a one-string change:

Waveform Character Best for
sine Pure, soft, no harmonics Gentle end-of-session chime
triangle Soft with a little edge Repeating reminders
square Buzzy, loud, electronic Urgent alarms, noisy rooms
sawtooth Harsh and brassy Attention-grabbing, tiring fast

For a focus timer, sine or triangle at moderate gain is usually right. For a HIIT interval or a kitchen timer competing with an extractor fan, square wins.

Making it loop until dismissed

Long-running alarms should not schedule 500 beeps up front. Queue a small window — say four seconds of pattern — and use a single setTimeout to top it up before that window runs out. The audio clock keeps each beep precise; the timeout only decides when to schedule the next batch, so lateness there costs nothing audible. This is the standard look-ahead scheduler pattern, and it is the same shape as the deadline-based approach a browser countdown timer uses to survive a backgrounded tab.

Oscillator or audio file: which should a timer use?

Both are valid. The trade is between total control at zero bytes and a richer sound you did not have to design.

Concern Oscillator Audio file
Bytes shipped 0 8–200 KB per sound
Can fail to load No Yes (404, slow network)
Offline / PWA Always works Needs cache strategy
Timbre Four basic waveforms Anything you can record
Pitch and length Runtime parameters Fixed at export

When an audio file still wins

A synthesised beep cannot be a bell, a marimba hit, or a recorded voice saying "time's up". If your brand needs a specific sound, ship the file. For small sounds you can inline it as a data URI rather than a separate request — encode the clip once with a Base64 encoder and drop the result into your bundle. It is bytes in your JS payload rather than a round trip, which is often the better trade for a sound under a few kilobytes.

Accessibility: sound is never the only signal

Some users have audio muted, are hard of hearing, or are in a meeting. Whatever the beep does, mirror it visually — a colour change, a flashing title, a document.title update. And give people a mute control that persists. MDN's best-practices guidance on user control is blunt about this: if your app makes noise, the user must be able to stop it.

The same reasoning applies to precision. If your timer is genuinely time-critical, the beep should fire off a stored absolute deadline, not an accumulated tick count. Working in epoch seconds makes that deadline trivial to log and compare — a Unix timestamp converter is the fastest way to sanity-check one against your logs.

References

Related on iKit

Related posts