TL;DR
howler.js is the best for sound effects and audio playback — handles format fallbacks (WebM/MP3/OGG), 3D spatial audio, audio sprites, and works across all browsers. tone.js is the synthesizer and music programming library — built on the Web Audio API for scheduling, synthesis, effects, and building music applications. wavesurfer.js is the waveform visualization library — displays audio as an interactive waveform for players, podcasts, and audio editors. These three solve different problems: use howler for game sounds and UI audio, tone.js for synthesis and DAW-like features, and wavesurfer.js for waveform display.
Key Takeaways
- howler.js: ~1.5M weekly downloads — multi-format, 3D audio, audio sprites, Web Audio + HTML5 fallback
- tone.js: ~600K weekly downloads — Web Audio synthesis, scheduling, effects chain, music apps
- wavesurfer.js: ~500K weekly downloads — waveform visualization, interactive playback, plugins
- All three are browser-based — Node.js doesn't have Web Audio API
- howler.js handles cross-browser format compatibility automatically
- tone.js uses a higher-level musical abstraction (notes, beats, BPM) over raw Web Audio nodes
howler.js
howler.js — audio made easy for modern web:
Basic usage
import { Howl, Howler } from "howler"
// Create and play a sound:
const sound = new Howl({
src: ["click.webm", "click.mp3"], // Format fallback — uses first supported
volume: 0.8,
loop: false,
preload: true, // Load immediately
})
// Play:
const id = sound.play()
// Control:
sound.pause(id)
sound.stop(id)
sound.seek(2.5, id) // Seek to 2.5 seconds
sound.volume(0.5, id) // Set volume 0-1
sound.rate(1.5, id) // Playback rate (1.5x speed)
sound.mute(true, id) // Mute without stopping
// Events:
const bgMusic = new Howl({
src: ["background.mp3"],
loop: true,
volume: 0.4,
onplay: () => console.log("Music started"),
onend: () => console.log("Music ended"),
onload: () => console.log("Audio loaded"),
onloaderror: (id, err) => console.error("Load failed:", err),
})
Audio sprites (multiple sounds in one file)
import { Howl } from "howler"
// Audio sprite — one file, multiple named clips (efficient for game sounds):
const uiSounds = new Howl({
src: ["ui-sounds.webm", "ui-sounds.mp3"],
sprite: {
click: [0, 200], // Start: 0ms, Duration: 200ms
hover: [300, 150], // Start: 300ms, Duration: 150ms
success: [600, 800], // Start: 600ms, Duration: 800ms
error: [1500, 600], // Start: 1500ms, Duration: 600ms
notification: [2200, 400],
},
})
// Play specific sprite:
uiSounds.play("click")
uiSounds.play("success")
// Preload everything in one HTTP request — much better than individual files
3D spatial audio
import { Howl } from "howler"
// 3D positional audio (uses PannerNode):
const footstep = new Howl({
src: ["footstep.mp3"],
pannerAttr: {
panningModel: "HRTF", // Head-related transfer function (most realistic)
distanceModel: "inverse",
refDistance: 1,
maxDistance: 100,
rolloffFactor: 2,
},
})
// Position the sound in 3D space:
footstep.pos(3, 0, -5) // x, y, z position
footstep.play()
// Move the listener (camera/player position):
Howler.pos(0, 0, 0) // Listener position
Howler.orientation(0, 0, -1, 0, 1, 0) // Direction + up vector
// Useful for: games, VR audio, 3D environments
Global controls
import { Howler } from "howler"
// Global mute (mutes ALL sounds):
Howler.mute(true)
Howler.mute(false)
// Global volume:
Howler.volume(0.5)
// Unload all sounds (free memory):
Howler.unload()
// Check codec support:
Howler.codecs("webm") // true/false
Howler.codecs("mp3") // true/false
Howler.codecs("ogg") // true/false
tone.js
tone.js — Web Audio synthesis and music programming:
Basic synthesis
import * as Tone from "tone"
// Start audio context (required after user interaction):
await Tone.start()
// Simple synthesizer:
const synth = new Tone.Synth().toDestination()
// Play a note:
synth.triggerAttackRelease("C4", "8n") // C4 for an eighth note
synth.triggerAttackRelease("A4", 0.5) // A4 for 0.5 seconds
synth.triggerAttackRelease("G3", "4n", Tone.now() + 1) // G3, starts in 1 second
// Trigger attack (hold) and release separately:
synth.triggerAttack("C5") // Start holding the note
await Tone.sleep(0.3)
synth.triggerRelease() // Release
Polyphonic synth and chords
import * as Tone from "tone"
await Tone.start()
// Polyphonic synth — play chords:
const polySynth = new Tone.PolySynth(Tone.Synth).toDestination()
// C major chord:
polySynth.triggerAttackRelease(["C4", "E4", "G4"], "2n")
// Schedule chord sequence:
const part = new Tone.Part((time, chord) => {
polySynth.triggerAttackRelease(chord, "2n", time)
}, [
[0, ["C4", "E4", "G4"]], // Beat 0: C major
[1, ["F4", "A4", "C5"]], // Beat 1: F major
[2, ["G4", "B4", "D5"]], // Beat 2: G major
[3, ["C4", "E4", "G4"]], // Beat 3: C major
])
part.start(0)
Tone.Transport.start()
Effects chain
import * as Tone from "tone"
await Tone.start()
// Chain effects:
const synth = new Tone.Synth()
const reverb = new Tone.Reverb({ decay: 2.5, wet: 0.4 })
const delay = new Tone.FeedbackDelay("8n", 0.3)
const compressor = new Tone.Compressor(-12, 3)
// Connect: synth → delay → reverb → compressor → output
synth.chain(delay, reverb, compressor, Tone.Destination)
synth.triggerAttackRelease("C4", "4n")
// Individual effect controls:
reverb.decay = 3 // Longer reverb tail
delay.feedback.value = 0.4 // More echo repeats
delay.delayTime.value = "16n"
Sequencer / beat machine
import * as Tone from "tone"
await Tone.start()
const kick = new Tone.MembraneSynth().toDestination()
const snare = new Tone.NoiseSynth({ envelope: { sustain: 0.05 } }).toDestination()
const hihat = new Tone.MetalSynth({ frequency: 400, envelope: { release: 0.1 } }).toDestination()
// 16-step sequencer:
const kickPattern = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]
const snarePattern = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0]
const hihatPattern = [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0]
let step = 0
const loop = new Tone.Sequence((time) => {
if (kickPattern[step]) kick.triggerAttackRelease("C1", "8n", time)
if (snarePattern[step]) snare.triggerAttackRelease("8n", time)
if (hihatPattern[step]) hihat.triggerAttackRelease("32n", time)
step = (step + 1) % 16
}, null, "16n")
Tone.Transport.bpm.value = 120
loop.start(0)
Tone.Transport.start()
wavesurfer.js
wavesurfer.js — interactive audio waveform visualization:
Basic usage
import WaveSurfer from "wavesurfer.js"
// Create waveform in a container:
const wavesurfer = WaveSurfer.create({
container: "#waveform", // CSS selector or DOM element
waveColor: "#FF8800",
progressColor: "#CC5500",
cursorColor: "#ffffff",
barWidth: 3,
barRadius: 3,
height: 128,
normalize: true, // Normalize waveform to fill height
})
// Load audio:
await wavesurfer.load("/podcast-episode.mp3")
// Playback controls:
wavesurfer.play()
wavesurfer.pause()
wavesurfer.stop()
wavesurfer.playPause()
// Seek:
wavesurfer.seekTo(0.5) // 50% through the track
wavesurfer.setTime(30) // Jump to 30 seconds
// Volume:
wavesurfer.setVolume(0.8)
wavesurfer.setMuted(true)
// Events:
wavesurfer.on("ready", () => {
console.log("Duration:", wavesurfer.getDuration())
})
wavesurfer.on("timeupdate", (currentTime) => {
updateProgressBar(currentTime)
})
wavesurfer.on("finish", () => {
playNextTrack()
})
React integration
import { useEffect, useRef } from "react"
import WaveSurfer from "wavesurfer.js"
interface AudioPlayerProps {
src: string
color?: string
}
export function AudioPlayer({ src, color = "#FF8800" }: AudioPlayerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const wavesurferRef = useRef<WaveSurfer | null>(null)
useEffect(() => {
if (!containerRef.current) return
wavesurferRef.current = WaveSurfer.create({
container: containerRef.current,
waveColor: color,
progressColor: color + "88",
height: 80,
barWidth: 2,
barRadius: 2,
})
wavesurferRef.current.load(src)
return () => {
wavesurferRef.current?.destroy()
}
}, [src])
return (
<div>
<div ref={containerRef} />
<button onClick={() => wavesurferRef.current?.playPause()}>
Play/Pause
</button>
</div>
)
}
Plugins
import WaveSurfer from "wavesurfer.js"
import RegionsPlugin from "wavesurfer.js/dist/plugins/regions"
import TimelinePlugin from "wavesurfer.js/dist/plugins/timeline"
// Timeline under waveform:
const timeline = TimelinePlugin.create({
height: 20,
timeInterval: 5,
primaryLabelInterval: 10,
})
// Regions (highlight segments):
const regions = RegionsPlugin.create()
const wavesurfer = WaveSurfer.create({
container: "#waveform",
plugins: [timeline, regions],
})
await wavesurfer.load("/audio.mp3")
// Add a region:
regions.addRegion({
start: 10,
end: 30,
content: "Interesting part",
color: "rgba(255, 136, 0, 0.3)",
drag: true,
resize: true,
})
regions.on("region-clicked", (region, e) => {
e.stopPropagation()
region.play()
})
Feature Comparison
| Feature | howler.js | tone.js | wavesurfer.js |
|---|---|---|---|
| Audio playback | ✅ Excellent | ✅ | ✅ |
| Sound effects | ✅ | ⚠️ (overkill) | ❌ |
| Synthesis | ❌ | ✅ Excellent | ❌ |
| Music scheduling | ❌ | ✅ | ❌ |
| Waveform display | ❌ | ❌ | ✅ Excellent |
| 3D spatial audio | ✅ | ✅ | ❌ |
| Format fallback | ✅ | ❌ | ✅ |
| Audio sprites | ✅ | ❌ | ❌ |
| Effects chain | ❌ | ✅ | ❌ |
| Recording | ❌ | ✅ | ✅ (plugin) |
| TypeScript | ✅ | ✅ | ✅ |
| React support | ✅ | ✅ | ✅ |
| Bundle size | ~30KB | ~400KB | ~100KB |
Web Audio API Architecture
All three libraries are built on top of the Web Audio API's node-based processing graph. Understanding this graph helps debug audio issues in production. Every audio signal flows through a chain of AudioNode objects: sources (oscillators, buffers, media elements) connect to processing nodes (gain, pan, convolution) which ultimately connect to the AudioDestinationNode representing the speaker output. Howler.js manages this graph internally — creating and destroying nodes as sounds are played and stopped. Tone.js exposes the graph explicitly, letting you chain effects with the .chain() method and inspect the graph during development. Wavesurfer.js creates a MediaElementSourceNode from the audio element and routes it through an AnalyserNode to read frequency data for waveform rendering. When audio playback fails silently, inspect AudioContext.state and the number of active nodes via the Chrome DevTools WebAudio inspector to understand the graph state.
Browser Compatibility and AudioContext Lifecycle
All three libraries depend on the Web Audio API's AudioContext, which browsers suspend by default until a user gesture occurs. This is the most common production bug in Web Audio applications: calling howl.play() or Tone.start() before a click, tap, or keypress triggers a browser warning and silently fails to play audio. Always gate audio initialization behind a user interaction event listener, and display a "Click to enable audio" prompt if your application starts audio programmatically. The AudioContext.state property returns "suspended", "running", or "closed" — check this before playing audio and call audioContext.resume() if suspended. howler.js handles this automatically by internally calling Howler.ctx.resume() on play attempts, while tone.js requires an explicit await Tone.start() call inside a user event handler.
Performance Considerations and Memory Management
Audio sprite patterns in howler.js dramatically reduce HTTP requests compared to loading individual sound files. A game UI with twenty distinct sound effects might require twenty separate HTTP requests, each with its own network overhead and audio decode time. Encoding all sounds into a single audio sprite reduces this to one request, and howler.js handles the timing slicing in memory after a single decode operation. The trade-off is that the single file must load completely before any sprite segment can play, which can delay initial audio availability. For tone.js applications with many synthesizer voices, monitor the number of active Tone.Synth instances — each represents an active OscillatorNode and GainNode pair in the Web Audio graph, and accumulating voices without calling dispose() causes memory leaks and audio glitches.
TypeScript Integration
Howler.js ships TypeScript declarations in @types/howler, while tone.js includes its own TypeScript definitions and was partially rewritten in TypeScript for v14. Wavesurfer.js v7 is written in TypeScript and ships declarations directly. For howler.js, the TypeScript definitions cover the main Howl and Howler APIs but the sprite typing requires casting — sound.play("sprite-name" as string) rather than a typed union of sprite keys. Tone.js's TypeScript coverage is the strongest: the synthesis chain API uses method chaining with correct return types, and the Transport scheduling functions have typed callbacks. If you're building a typed audio system, tone.js's TypeScript definitions enable catching mismatched note format strings (using "C#4" where "Db4" is expected) at the type level with string literal unions.
React Integration Patterns
Integrating these libraries with React requires careful attention to cleanup to prevent multiple audio contexts from being created on re-renders. All three libraries should be initialized inside a useEffect with an empty dependency array and cleaned up in the effect's return function. For wavesurfer.js, call wavesurfer.destroy() in the cleanup function to release the Web Audio nodes and <canvas> memory. For howler.js, call Howler.unload() if the component manages all audio, or track individual Howl instances and call sound.unload(). Tone.js requires Tone.Transport.stop() and disposing individual instruments and effects with their .dispose() methods. React Strict Mode in development renders effects twice, which can cause audio context duplication — use a ref to track whether the audio system has been initialized.
Production Deployment and Asset Serving
Audio files require specific server configuration for optimal delivery. Serve WebM/Ogg files with Content-Type: audio/webm and MP3 files with Content-Type: audio/mpeg — incorrect MIME types cause some browsers to reject the files. For howler.js audio sprites, the single sprite file can be large (several MB for a complete game sound set) — serve from a CDN with proper Cache-Control: public, max-age=31536000 headers and use a content hash in the filename to enable long-term caching. Wavesurfer.js's waveform rendering requires reading the full audio file before drawing the waveform — for long podcast episodes, use the splitChannels option with backend: "MediaElement" to enable streaming playback while the waveform renders progressively, rather than waiting for the entire file to buffer.
Mobile Browser Constraints and Autoplay Policies
Mobile browsers enforce strict autoplay restrictions for audio — any audio that plays without a direct user gesture is blocked. This affects all three libraries. With howler.js, the autoplay option and any play() call made outside a user interaction event handler will be silently blocked on mobile Safari and Chrome for Android. The workaround is to call Howler.ctx.resume() inside a user interaction handler (button click, touch start) to resume the AudioContext, then play audio. Tone.js requires explicitly calling Tone.start() inside a user gesture before any scheduling or playback — failing to do so results in the AudioContext remaining in the suspended state, and no sound is produced. Tone.start() returns a promise that resolves when the AudioContext is running. Wavesurfer.js's playback button triggers audio through a user gesture by design, making it less susceptible to autoplay restrictions, but programmatic playback via wavesurfer.play() called on page load will fail on mobile. Always gate any programmatic audio playback behind a user gesture check before invoking play methods.
When to Use Each
Choose howler.js if:
- Game sound effects, UI audio feedback (clicks, notifications)
- Background music that needs format fallbacks across browsers
- 3D spatial audio for immersive experiences
- Audio sprites for efficient multiple-sound loading
Choose tone.js if:
- Building a music application, synthesizer, or digital instrument
- Precise audio scheduling and beat-synced playback
- Audio effects chains (reverb, delay, compression)
- Generative music, MIDI-like sequencing
Choose wavesurfer.js if:
- Podcast player with visual waveform
- Audio editor with region selection
- Music player showing song waveform
- Interview clips, audio transcription tools
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on howler.js v2.x, tone.js v14.x, and wavesurfer.js v7.x.
Compare audio and multimedia packages on PkgPulse →
See also: React vs Vue and React vs Svelte, culori vs chroma-js vs tinycolor2.