Playing Sounds With the Web Audio API
You want to play audio in the browser with precise control—scheduling, effects, synthesis—but the <audio> element falls short. The Web Audio API solves this, yet its documentation mixes deprecated patterns with current best practices. This article clarifies the modern approach to Web Audio API sound playback, covering AudioContext basics, source nodes, and the audio graph model without the legacy baggage.
Key Takeaways
- Create one
AudioContextper application and always handle the suspended state withresume()after user interaction - Use
AudioBufferSourceNodefor pre-loaded audio files andOscillatorNodefor synthesized tones—both are single-use nodes - Avoid deprecated
ScriptProcessorNode; useAudioWorkletfor custom audio processing - Treat output device routing via
setSinkId()as experimental with incomplete browser support
Understanding AudioContext Basics
Every Web Audio application starts with an AudioContext. This object manages all audio operations and provides the clock for scheduling. Think of it as the runtime environment for your audio graph.
const audioContext = new AudioContext()
A critical constraint: browsers suspend new contexts until a user gesture occurs. Always check audioContext.state and call resume() from a click or keypress handler:
button.addEventListener('click', async () => {
if (audioContext.state === 'suspended') {
await audioContext.resume()
}
// Now safe to play audio
})
In most applications, a single AudioContext is sufficient and recommended. Creating multiple contexts increases resource usage and makes timing and synchronization harder to manage. (See: https://developer.mozilla.org/en-US/docs/Web/API/AudioContext)
The Audio Graph Model
Web Audio uses a directed graph of nodes. Audio flows from source nodes, through processing nodes, to a destination. The audioContext.destination represents the default output—typically the user’s speakers.
Nodes connect via the connect() method:
sourceNode.connect(gainNode)
gainNode.connect(audioContext.destination)
This modular design lets you insert filters, gain controls, or analyzers anywhere in the chain. Disconnect nodes with disconnect() when reconfiguring the graph.
Source Nodes for Sound Playback
Two source types handle most playback scenarios.
AudioBufferSourceNode
For pre-loaded audio files, decode the data into an AudioBuffer, then play it through an AudioBufferSourceNode:
const response = await fetch('sound.mp3')
const arrayBuffer = await response.arrayBuffer()
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
const source = audioContext.createBufferSource()
source.buffer = audioBuffer
source.connect(audioContext.destination)
source.start()
Each AudioBufferSourceNode plays once. Create a new source node for each playback—they’re lightweight. The underlying AudioBuffer is reusable.
Note that while decodeAudioData() supports promises in modern browsers, older Safari versions required the callback form. This is mostly a legacy concern but still relevant for long-tail compatibility.
OscillatorNode
For synthesized tones, use OscillatorNode:
const oscillator = audioContext.createOscillator()
oscillator.type = 'sine'
oscillator.frequency.setValueAtTime(440, audioContext.currentTime)
oscillator.connect(audioContext.destination)
oscillator.start()
oscillator.stop(audioContext.currentTime + 1)
Oscillators also play once. Schedule start() and stop() times using audioContext.currentTime for sample-accurate playback.
Discover how at OpenReplay.com.
AudioWorklet vs ScriptProcessor
For custom audio processing, avoid ScriptProcessorNode. It’s deprecated, runs on the main thread, and causes audio glitches under load.
AudioWorklet is the modern replacement. It runs in a dedicated audio rendering thread with deterministic timing:
// processor.js
class MyProcessor extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
// Process audio here
return true
}
}
registerProcessor('my-processor', MyProcessor)
// main.js
await audioContext.audioWorklet.addModule('processor.js')
const workletNode = new AudioWorkletNode(audioContext, 'my-processor')
workletNode.connect(audioContext.destination)
AudioWorklet is supported in all modern evergreen browsers. The main constraints tend to be environment-related (secure context, bundling, cross-origin setup) rather than lack of API support.
Web Audio Output Routing Constraints
The API’s output routing capabilities are limited and browser-dependent. By default, audio routes to audioContext.destination, which maps to the system’s default output device.
AudioContext.setSinkId() allows selecting specific output devices, but treat this as experimental and highly constrained. It requires a secure context (HTTPS), user permission, and appropriate permissions policy headers. In practice, it is not available on Safari or iOS and should not be relied on for cross-platform speaker switching.
For applications requiring output device selection, detect support explicitly:
if (typeof audioContext.setSinkId === 'function') {
// Presence does not guarantee usability; permissions and policy may still block it
}
Even when the method exists, calls may fail due to policy or platform limitations. Build fallbacks and communicate these constraints clearly to users.
Practical Considerations
Autoplay policies block audio until user interaction. Design your UI to require a click before initializing playback.
CORS restrictions apply when fetching audio files cross-origin. Ensure proper headers or host files on the same origin.
Memory management matters for buffer-heavy applications. Dereference unused AudioBuffer objects and disconnect nodes you’re finished with.
Mobile browsers impose additional constraints—iOS Safari in particular. Test on real devices, not just simulators.
Conclusion
The Web Audio API provides powerful, low-level audio control through a graph-based model. Start with a single AudioContext, respect autoplay policies, and use modern patterns: AudioBufferSourceNode for samples, OscillatorNode for synthesis, and AudioWorklet for custom processing. Treat output device routing as highly constrained and platform-dependent. Feature-detect everything, and build for the constraints browsers actually impose.
FAQs
Browsers block AudioContext creation until user interaction occurs. Your development environment may have more permissive settings. Always check audioContext.state and call resume() inside a click or keypress handler before attempting playback. This autoplay policy applies universally across modern browsers.
No. AudioBufferSourceNode instances are single-use by design. After calling start(), the node cannot be restarted. Create a new source node for each playback while reusing the underlying AudioBuffer. Source nodes are lightweight, so this pattern has minimal performance impact.
Use AudioContext.setSinkId(), but treat it as highly constrained. It requires HTTPS, user permission, and permissive policies, and it is not supported on Safari or iOS. Always feature-detect, handle failures gracefully, and inform users when device selection is unavailable.
ScriptProcessorNode is deprecated and runs on the main thread, causing audio glitches during heavy processing. AudioWorklet runs in a dedicated audio rendering thread with deterministic timing, making it suitable for real-time audio manipulation. Always use AudioWorklet for custom processing in new projects.
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.