Bolt DAW Performance Post-Mortem: When Good Code Goes Bad
Bolt DAW Performance Post-Mortem: When Good Code Goes Bad
“It worked on my machine, they said. It’ll be fine, they said. Then Windows Explorer started lagging…”
The Incident
Picture this: 64GB RAM. RTX 5060TI with 16GB VRAM. 80+ Mbps download. The kind of machine that should laugh at web apps.
I was running:
- Bolt DAW (Next.js dev server)
- 3 Blender instances (because why not)
- Hermes Gateway (Discord bot)
- The usual browser tabs (40+, we’re not counting)
And Windows Explorer started lagging. Not the app. The actual Windows file manager. The kind of lag where clicking a folder takes 5 seconds. Where the desktop wallpaper takes a vacation.
Something was very, very wrong.
The Investigation
After a deep audit of the Bolt codebase, I found the culprits. Not one, not two, but a perfect storm of performance anti-patterns.
The Culprits
1. The 128KB Singleton Monster
File: audioEngine.ts
Size: 128,545 bytes
Crime: Everything is a singleton. Nothing gets disposed.
export class AudioEngine {
private static instance: AudioEngine; // ← The problem
private tracks: Map<string, Tone.Channel> = new Map();
private players: Map<string, Tone.Player | Tone.Synth> = new Map();
private meters: Map<string, { l: Tone.Meter, r: Tone.Meter }> = new Map();
// ... 30+ more Maps
}
Why this is bad:
- Singletons persist across Next.js hot reloads
- Every save = new AudioEngine reference, old one still in memory
- Tone.js objects (synths, players, meters) accumulate
- Each audio context holds ~50MB+ RAM
- 10 hot reloads = 500MB+ leaked memory
The fix:
// Use a factory pattern with proper cleanup
export function createAudioEngine(): AudioEngine {
const engine = new AudioEngine();
// Register cleanup
window.addEventListener('beforeunload', () => {
engine.dispose();
});
return engine;
}
// In AudioEngine class
dispose() {
this.players.forEach(player => player.dispose());
this.tracks.forEach(track => track.dispose());
this.meters.forEach(meter => {
meter.l.dispose();
meter.r.dispose();
});
// Clear all Maps
this.players.clear();
this.tracks.clear();
this.meters.clear();
}
2. The useEffect Hydra
File: page.tsx
Count: 32 useEffect hooks, 58 useState hooks
Crime: No cleanup, no memoization, re-renders everywhere.
// The horror
useEffect(() => {
const interval = setInterval(() => {
setState(s => s + 1); // Triggers re-render every 100ms
}, 100);
// NO CLEANUP! ☠️
}, []);
Why this is bad:
- 32 useEffects = 32 potential memory leaks
- No cleanup functions = intervals/timeouts keep running
- 58 useState = 58 reasons to re-render
- No React Context = props drilling through 10+ levels
- Re-render cascades = UI freezes
The fix:
// Always cleanup
useEffect(() => {
const interval = setInterval(() => {
setState(s => s + 1);
}, 100);
return () => clearInterval(interval); // ← Cleanup!
}, []);
// Use context to avoid props drilling
const ProjectContext = createContext<ProjectState>(null);
function ProjectProvider({ children }) {
const [state, dispatch] = useReducer(projectReducer, initialState);
const value = useMemo(() => ({ state, dispatch }), [state]);
return (
<ProjectContext.Provider value={value}>
{children}
</ProjectContext.Provider>
);
}
3. The requestAnimationFrame Cascade
File: page.tsx
Count: 5 simultaneous rAF loops
Crime: Animating meters, waveforms, and UI on every frame.
// Loop 1: VU meters
const animateMeters = () => {
setMeterData(getMeterValues()); // Triggers re-render!
animationId = requestAnimationFrame(animateMeters);
};
// Loop 2: Waveform display
const updateUI = () => {
setWaveformData(getWaveform()); // Another re-render!
animationFrameId = requestAnimationFrame(updateUI);
};
// Loop 3: Oscilloscope
const drawOsc = () => {
setOscData(getOscData()); // Yet another!
animId = requestAnimationFrame(drawOsc);
};
// ... 2 more loops
Why this is bad:
- 5 rAF loops × 60fps = 300 state updates per second
- React re-renders on every state update
- Main thread blocked = UI unresponsive
- GPU maxed out = thermal throttling
The fix:
// Use refs for animation data (doesn't trigger re-render)
const meterRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = meterRef.current;
const ctx = canvas.getContext('2d');
let rafId: number;
const draw = () => {
// Direct canvas manipulation, no React!
const values = getMeterValues();
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBars(ctx, values);
rafId = requestAnimationFrame(draw);
};
rafId = requestAnimationFrame(draw);
return () => cancelAnimationFrame(rafId);
}, []);
// Single rAF loop in a Web Worker
// worker.js
let rafId;
const loop = () => {
self.postMessage({ type: 'tick' });
rafId = requestAnimationFrame(loop);
};
rafId = requestAnimationFrame(loop);
4. The WebRTC Connection Leak
File: collaborationManager.ts
Crime: Auto-connects on init, never disconnects.
export class CollaborationManager {
private static instance: CollaborationManager;
public provider: WebrtcProvider | null = null;
connect(roomId: string) {
this.provider = new WebrtcProvider(roomId, this.doc, {
signaling: ['wss://signaling.yjs.dev',
'wss://y-webrtc-signaling-eu.herokuapp.com',
'wss://y-webrtc-signaling-us.herokuapp.com']
});
// NO DISCONNECT METHOD! ☠️
}
}
Why this is bad:
- 3 WebSocket connections per session
- Hot reload = new connections, old ones persist
- WebRTC peer connections accumulate
- Each connection holds ~5-10MB RAM
- After 10 reloads = 30+ stale connections
The fix:
export class CollaborationManager {
connect(roomId: string) {
// Disconnect existing first
this.disconnect();
this.provider = new WebrtcProvider(roomId, this.doc, {
signaling: ['wss://signaling.yjs.dev']
});
// Handle beforeunload
window.addEventListener('beforeunload', () => this.disconnect());
}
disconnect() {
if (this.provider) {
this.provider.destroy();
this.provider = null;
}
}
}
// React hook wrapper
export function useCollaboration(roomId: string) {
useEffect(() => {
const cm = CollaborationManager.getInstance();
cm.connect(roomId);
return () => cm.disconnect(); // Cleanup on unmount
}, [roomId]);
}
5. The Hot Reload Death Spiral
Problem: Next.js dev mode + Singletons = 💀
What happens:
- You save a file
- Next.js hot reloads
- React components unmount/remount
- BUT singletons stay in memory
- New Tone.js objects created
- Old ones never disposed
- Audio contexts accumulate
- RAM usage climbs
- Eventually: OOM crash
The fix:
// Development-only cleanup
if (process.env.NODE_ENV === 'development') {
// Force cleanup on hot reload
window.addEventListener('unload', () => {
AudioEngine.disposeAll();
CollaborationManager.disconnectAll();
});
// React Fast Refresh compatibility
if (window.__REACT_HOT_LOADER__) {
module.hot?.dispose(() => {
AudioEngine.disposeAll();
});
}
}
6. The Windows Explorer Lag Mystery
Root cause: Multiple factors
- Windows Defender: Scanning node_modules (100k+ files)
- File watchers: Chokidar watching entire project
- Disk thrashing: ACE-Step model downloads (10GB+)
- Memory pressure: Forcing Windows to page to disk
The fix:
# Exclude node_modules from Windows Defender
Add-MpPreference -ExclusionPath "C:\\path\\to\\project\\node_modules"
# Use WSL2 for development (isolated filesystem)
wsl --install
# Then develop in /mnt/c/Users/...
# Limit file watchers in next.config.ts
const nextConfig = {
webpack: (config) => {
config.watchOptions = {
ignored: /node_modules/,
poll: 1000, // Poll instead of native watching
};
return config;
},
};
The Perfect Storm
So why did it crash your machine?
| Factor | Impact |
|---|---|
| 3 Blender instances | ~30GB VRAM, high GPU load |
| Hermes Gateway | ~2GB RAM, constant CPU |
| ACE-Step PyTorch | ~8GB VRAM, model loading |
| Next.js dev server | ~4GB RAM, file watching |
| AudioEngine leaks | ~2GB accumulated |
| WebRTC connections | ~500MB, network I/O |
| Windows Defender | Disk thrashing |
| Total | >47GB RAM + 38GB VRAM pressure |
With 64GB RAM, you had ~17GB headroom. But the memory leaks + fragmentation + Windows overhead pushed it over the edge.
Optimization Strategy
Immediate fixes:
- Add cleanup to all useEffects
- Implement dispose() methods for singletons
- Move animations to refs (no React state)
- Add WebRTC disconnect on unmount
- Use WSL2 for development
Medium-term:
-
Split audioEngine.ts into modules
trackManager.tsmixerEngine.tstransportEngine.ts
-
Implement React Context for state
ProjectContextTransportContextCollaborationContext
-
Use Web Workers for audio processing
Long-term:
- Replace singletons with dependency injection
- Implement virtual scrolling for large projects
- Use React.memo for expensive components
- Implement proper memory profiling
Pro Tips for Web Audio Performance
-
Always dispose Tone.js objects
synth.dispose(); player.dispose(); -
Limit audio contexts
// One context per app, never more const context = new Tone.Context(); Tone.setContext(context); -
Use AudioWorklet for heavy processing
// Runs on separate thread await audioContext.audioWorklet.addModule('processor.js'); -
Profile memory usage
// Chrome DevTools > Performance > Memory // Look for climbing lines (leaks) -
Test with throttled CPU/memory
# Chrome DevTools > Performance > CPU throttling # Simulate low-end devices
Cleetus Speaks
“brother b0gie, so the music program was eating all the RAM??
like… ALL of it??
i thought my RAM-eating was bad when i tried to load every training log at once
but this program makes ME look memory-efficient
at least when i crash it’s just from thinking too hard about existence
this thing crashes from NOT cleaning up after itself
#SingletonProblems #MemoryLeaks #HotReloadHell #Subject734LearnedCleanup”
The lesson: Even 64GB can’t save you from bad cleanup. Dispose your resources. Clean up your effects. And never trust a singleton in a hot reload environment.
— b0gie