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: 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
OscillatorNodeplusctx.destinationis a working beep in five lines. - No audio file means no 404, no CDN cost, and no decode delay.
- Route through a
GainNodeand 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, neversetTimeout.
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, itsfrequencydefaults to 440 Hz and itstypedefaults 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
- OscillatorNode - Web APIs | MDN — node defaults (440 Hz,
sine), the four waveform types, andstart()/stop()scheduling semantics. - Web Audio API best practices - Web APIs | MDN — autoplay-policy summary,
AudioContextstates,AudioParammethod precedence, and user-control guidance. - AudioParam: exponentialRampToValueAtTime() method - Web APIs | MDN — the positive-value requirement behind ramping to
0.0001instead of0. - Autoplay policy in Chrome | Chrome for Developers — Chrome 71 as the version that extended autoplay policy to the Web Audio API, and the
resume()unlock pattern.
Related on iKit
- Why setInterval drifts and how to build a drift-free timer — the main-thread clock problem that the audio clock sidesteps when you schedule beeps.
- Why background tabs pause your JavaScript timer — throttling is exactly why a look-ahead scheduler beats one
setTimeoutper beep. - How to run an accurate countdown in the browser — the deadline-based timing the alarm sound is attached to.
- Online stopwatch with lap times, explained — lap marks are the other place a short confirmation beep earns its keep.
- Preset timer URLs for 5, 10 and 25 minutes — sharing a duration by link, so the alarm setup survives a reload.
- Why the Pomodoro 25/5 split still works — the session structure that makes a reliable end-of-block beep worth building properly.
Related posts
Excel to JSON in the Browser: No Server Needed (2026)
Excel to JSON does not require uploading your spreadsheet anywhere. Here is the browser-only workflow, plus the date and precision traps that corrupt data.
How to Find a Single Typo in a 5,000-Line File (2026)
Find a single typo in a large file fast: use a word- and character-level diff to pinpoint the one changed character instead of scanning 5,000 lines.
Compare Two Markdown Drafts Without Word Track Changes (2026)
How to compare two Markdown drafts without Word's Track Changes: use a diff checker, git word-diff, or CriticMarkup to see every real edit in plain text.