Top 10 Bugs from 203 Canvas/DOM Components — Patterns Revealed by Real Data
Go deeper on this topic
Canvas vs DOM & Interactive UI
Canvas vs DOM tradeoffs, CSS interactions, custom physics, and portfolio design
Article 2 of 5 in this series.
Next reads
Interactive UI with Pure CSS — Practical Use of transform, transition, and Custom Properties
CSS techniques using transform, transition, and custom properties demonstrated through DOM+CSS-based interactive UI components like FidgetSpinner, InfiniteKnob, and ToggleSwitch.
The Limits of a 180-Line Custom Physics Engine — Why I Dropped Matter.js and What I Couldn't Cover
Built a custom 2D physics engine in 166 lines of physics.ts, reducing Matter.js's 300KB to 4KB. The trade-offs of focusing on gravity, collision, and bounce while giving up constraints, friction, and stacking.
Building a Custom Physics Engine for the Browser from Scratch
How I built a lightweight 180-line physics engine in TypeScript instead of using Matter.js for my portfolio site. Reduced bundle size from 300KB to 4KB while maintaining 60fps.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
Introduction
This site currently has 211 interactive components. Fluid simulation, piano, maze generation, pinball, ant pheromone simulation — the genres vary wildly, but the bug patterns they share are surprisingly similar.
This article compiles the "Top 10 most common bugs" revealed by actually tallying git history and commit logs. For each pattern, I show the problem, impact scope, fix pattern, and actual code examples.
The goal is simple: never step on the same bug twice. I wrote this as a checklist for myself, but it should also be useful for anyone building interactive components with Canvas API/DOM.
Key commits used for the tally:
08925bcb— Timer-related leak fixes04163d8e— AudioContext, mountedRef, Clipboard fixes (18 components)f51f0c83— Module-level mutable state elimination (15 components)1fc14711— NaN safety guards, Canvas size validation (8 components)7723cfaf— Passive event listener additions (16 components)441e1bf7— rAF double-start, delta time fixes
1. setTimeout / setInterval Memory Leaks
Impact scope: 18+ components
The most common bug in game-type components. Using setTimeout or setInterval inside a component but forgetting to clear them on unmount. The result: callbacks continue executing after unmount, accessing non-existent DOM or calling setState.
Problematic code:
// ❌ MazeRunner.tsx — Leaky version
function MazeRunner() {
const [timeLeft, setTimeLeft] = useState(60)
useEffect(() => {
const id = setInterval(() => {
setTimeLeft((t) => t - 1)
}, 1000)
// Forgot cleanup → setState keeps running after unmount
}, [])
}Fix pattern:
// ✅ Manage timer ID with useRef + clear in cleanup function
function MazeRunner() {
const [timeLeft, setTimeLeft] = useState(60)
const timerRef = useRef<ReturnType<typeof setInterval>>(null)
useEffect(() => {
timerRef.current = setInterval(() => {
setTimeLeft((t) => t - 1)
}, 1000)
return () => {
if (timerRef.current) {
clearInterval(timerRef.current)
}
}
}, [])
}The key is holding the timer ID in useRef. When chaining additional setTimeout calls from within a callback, local variables alone can't track them. Storing in useRef lets you reference and clear the latest ID from anywhere.
2. AudioContext Initialization Failure
Impact scope: 18 components (commit 04163d8e)
Frequent in components using Web Audio API. Due to browser autoplay policies, creating an AudioContext before user interaction puts it in suspended state. Furthermore, in some environments (older Safari, memory-constrained mobile), new AudioContext() itself can throw an exception.
Problematic code:
// ❌ Kalimba.tsx — No guards
function Kalimba() {
const audioCtx = useRef(new AudioContext())
function playNote(freq: number) {
const osc = audioCtx.current.createOscillator()
osc.frequency.value = freq
osc.connect(audioCtx.current.destination)
osc.start()
}
}Fix pattern:
// ✅ Wrap in try-catch + resume() for suspended state
function Kalimba() {
const audioCtx = useRef<AudioContext | null>(null)
function getAudioContext(): AudioContext | null {
if (!audioCtx.current) {
try {
audioCtx.current = new AudioContext()
} catch {
console.warn('AudioContext not available')
return null
}
}
if (audioCtx.current.state === 'suspended') {
audioCtx.current.resume().catch(() => {})
}
return audioCtx.current
}
function playNote(freq: number) {
const ctx = getAudioContext()
if (!ctx) return
const osc = ctx.createOscillator()
osc.frequency.value = freq
osc.connect(ctx.destination)
osc.start()
}
}This fix was applied to 18 components. Once the pattern is established, it's mechanical to apply. The problem is that it takes a long time to notice in the first place. It was only discovered when someone reported "no sound on mobile Safari."
3. Module-Level Mutable State
Impact scope: 8 components (commit f51f0c83)
This bug surfaces in React 18's Strict Mode. Placing a counter like let nextId = 0 at module scope causes ID collisions during Strict Mode's double mount.
Problematic code:
// ❌ Match3.tsx — Module-level counter
let nextId = 0
function createTile(color: string) {
return { id: nextId++, color }
}
function Match3() {
const [tiles, setTiles] = useState(() =>
Array.from({ length: 64 }, () => createTile(randomColor()))
)
// Strict Mode runs twice → IDs split into 0-63 and 64-127
// The first initialization result is discarded, but nextId has advanced to 128
}Fix pattern:
// ✅ Instance-level counter with useRef
function Match3() {
const nextId = useRef(0)
function createTile(color: string) {
return { id: nextId.current++, color }
}
const [tiles, setTiles] = useState(() =>
Array.from({ length: 64 }, () => createTile(randomColor()))
)
}This is documented in React's official docs, but it doesn't click until you actually make the mistake. Game components especially tend to put things at module scope for performance reasons, so extra caution is needed.
4. NaN / Infinity Propagation
Impact scope: 8+ components (commit 1fc14711)
Inevitable in components centered on numerical calculations. Once NaN occurs in a single frame, every subsequent frame is corrupted. Canvas doesn't throw errors when given NaN — rendering simply disappears, making debugging difficult.
Problematic code:
// ❌ Mandelbrot — NaN in divergence check
function smoothColor(zn: number, iter: number) {
// When zn is 0 or below, Math.log returns NaN → all pixels turn black
const smooth = iter - Math.log(Math.log(zn)) / Math.log(2)
return smooth
}Fix pattern:
// ✅ Clamp input to safe range
function smoothColor(zn: number, iter: number) {
const smooth = iter - Math.log2(Math.max(Math.log2(zn), 1e-10))
return Number.isFinite(smooth) ? smooth : iter
}In DoublePendulum, angular velocity would spike to Infinity. The fix is simply adding Number.isFinite guards to all calculation results, but neglecting this leads to a hard-to-reproduce bug where "the simulation occasionally freezes."
// DoublePendulum — Guard all update results
const newOmega1 = computeOmega1(theta1, theta2, omega1, omega2)
if (!Number.isFinite(newOmega1)) {
// On divergence, keep previous frame's value
return prevState
}5. Canvas Size 0 Crash
Impact scope: 8 components (commit 1fc14711)
createImageData(0, 0) and new OffscreenCanvas(0, 0) throw exceptions. This occurs during SSR, before container layout completes, or when CSS sets display: none.
Problematic code:
// ❌ ReactionDiffusion.tsx
function ReactionDiffusion() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current!
const ctx = canvas.getContext('2d')!
// Container not loaded → width=0, height=0
const imageData = ctx.createImageData(canvas.width, canvas.height) // 💥
}, [])
}Fix pattern:
// ✅ Size validation
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const { width, height } = canvas.getBoundingClientRect()
const w = Math.max(1, Math.floor(width))
const h = Math.max(1, Math.floor(height))
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return
const imageData = ctx.createImageData(w, h) // Safe
}, [])The key is guaranteeing a minimum of 1 with Math.max(1, ...). Just checking for 0 and returning would complicate re-initialization logic after resize. Guaranteeing a minimum of 1px keeps the overall code simpler.
6. Missing mountedRef
Impact scope: 18+ components (commit 04163d8e)
Common in components calling setState after async operations like getUserMedia(), fetch(), or AudioContext.decodeAudioData(). If a Promise resolves after the component has unmounted, React issues a warning (though in React 18+, the actual harm is minimal, unnecessary processing still runs).
Problematic code:
// ❌ AudioVisualizer.tsx
function AudioVisualizer() {
const [stream, setStream] = useState<MediaStream | null>(null)
useEffect(() => {
navigator.mediaDevices.getUserMedia({ audio: true }).then((s) => {
setStream(s) // May execute after unmount
})
}, [])
}Fix pattern:
// ✅ Guard with mountedRef
function AudioVisualizer() {
const [stream, setStream] = useState<MediaStream | null>(null)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((s) => {
if (mountedRef.current) {
setStream(s)
} else {
// If already unmounted, release resources
s.getTracks().forEach((t) => t.stop())
}
})
.catch(() => {})
return () => {
mountedRef.current = false
}
}, [])
}For MediaStream specifically, in addition to the mountedRef check, failing to stop tracks in the else branch leaves the microphone in use. This oversight actually led to a "browser microphone indicator won't turn off" bug.
7. requestAnimationFrame Double Start
Impact scope: 15+ components (commit 441e1bf7)
Starting a requestAnimationFrame loop inside useEffect where dependency array changes or Strict Mode remounting causes two loops to run simultaneously. With updates happening twice per frame, physics simulations run at double speed or rendering flickers.
Problematic code:
// ❌ ParticleField.tsx
useEffect(() => {
let rafId: number
function loop() {
update()
draw()
rafId = requestAnimationFrame(loop)
}
loop()
return () => cancelAnimationFrame(rafId)
// ⚠️ Strict Mode runs twice → after 1st cleanup, 2nd starts
// → Visually appears fine but consumes 2x CPU
}, [particles]) // Loop multiplies every time particles changesFix pattern:
// ✅ Extract to useCanvasGameLoop.ts
function useCanvasGameLoop(
callback: (dt: number) => void,
deps: unknown[] = [],
) {
const callbackRef = useRef(callback)
callbackRef.current = callback
useEffect(() => {
let rafId: number
let lastTime = performance.now()
let running = true
function loop(now: number) {
if (!running) return
const dt = Math.min((now - lastTime) / 1000, 0.1) // 100ms cap
lastTime = now
callbackRef.current(dt)
rafId = requestAnimationFrame(loop)
}
rafId = requestAnimationFrame(loop)
return () => {
running = false
cancelAnimationFrame(rafId)
}
}, deps)
}The callbackRef pattern is key for removing the callback function from the dependency array. The loop doesn't need to restart every time the callback changes — just update the reference. Additionally, the running flag ensures the loop stops even if cancelAnimationFrame doesn't fire in time.
8. Missing Passive Event Listeners
Impact scope: 31 listeners / 16 components (commit 7723cfaf)
Not adding { passive: true } to touchmove and wheel events forces the browser to consider the possibility that the listener might call preventDefault(), blocking scroll. The result: jank (stuttering) during scrolling.
Problematic code:
// ❌ GravityBadges.tsx
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
applyForce(e.deltaY)
}
window.addEventListener('wheel', handleWheel)
return () => window.removeEventListener('wheel', handleWheel)
}, [])Fix pattern:
// ✅ Explicitly set passive: true
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
applyForce(e.deltaY)
}
window.addEventListener('wheel', handleWheel, { passive: true })
return () => window.removeEventListener('wheel', handleWheel)
}, [])Additionally, we unified on the Pointer Events API. Instead of registering mousedown/touchstart separately, we use a single pointerdown and specify touch-action: none in CSS. The number of event listeners dropped by more than half, and code clarity improved.
/* Set touch-action on Canvas to suppress browser default behavior */
canvas.interactive {
touch-action: none;
}9. Unguarded Clipboard API
Impact scope: 2-3 components (commit 04163d8e)
Small impact scope, but a nasty bug that completely breaks functionality in certain environments. navigator.clipboard.writeText() requires HTTPS and transient user activation. In HTTP environments, sandboxed iframes, and certain Firefox versions, navigator.clipboard itself is undefined.
Problematic code:
// ❌ ColorPicker.tsx
async function copyColor(hex: string) {
await navigator.clipboard.writeText(hex)
showToast('Copied!')
}Fix pattern:
// ✅ Optional chaining + try-catch + fallback
async function copyColor(hex: string) {
try {
await navigator.clipboard?.writeText(hex)
showToast('Copied!')
} catch {
// Fallback: copy via hidden input
const input = document.createElement('input')
input.value = hex
input.style.position = 'fixed'
input.style.opacity = '0'
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
showToast('Copied!')
}
}document.execCommand('copy') is deprecated but still works as a fallback. Avoiding the state where "the copy button does nothing" is more important than pursuing perfection.
10. Delta Time Not Reset
Impact scope: 5+ components (commit 441e1bf7)
When a tab returns from the background, performance.now()-based delta time calculations produce dt values of several seconds to tens of seconds. In physics simulations, "calculate 5 seconds in one frame" causes objects to teleport off-screen.
Problematic code:
// ❌ dt=5.0 on tab return
function loop(now: number) {
const dt = (now - lastTime) / 1000
lastTime = now
physics.step(dt) // dt=5.0 → ball flies off screen
requestAnimationFrame(loop)
}Fix pattern:
// ✅ Cap dt + reset on visibilitychange
function loop(now: number) {
const dt = Math.min((now - lastTime) / 1000, 1 / 30) // Max 33ms
lastTime = now
physics.step(dt)
requestAnimationFrame(loop)
}
// Reset lastTime on visibilitychange
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
lastTime = performance.now()
}
})Math.min(dt, 1/30) caps the maximum at 33ms. Even on tab return, at most one frame's worth of updates runs. Combined with visibilitychange reset, this becomes even more reliable.
Lessons: What Canvas Bug Patterns Taught Me
80% of Bugs Concentrate in "Lifecycle Management"
Of the Top 10, six — #1 (timers), #2 (AudioContext), #3 (module state), #6 (mountedRef), #7 (rAF double start), and #10 (delta time) — are bugs related to mount/unmount/remount.
React components must be written with the assumption that "they can disappear at any time," but this assumption is easy to forget when using imperative APIs like Canvas and audio.
Canvas-Specific Bugs Are Easy to Miss
#4 (NaN propagation) and #5 (size 0) are Canvas-specific problems. In DOM, setting NaN just makes things look wrong and you get errors, but Canvas breaks silently. No console.error, no throw. With no debugging foothold, discovery is delayed.
Efficiency of Bulk Fixes
Six commits fixed 100+ components total. Once a pattern is established, the remaining fixes become mechanical work:
- Establish the pattern on one component
- Use
grepto identify other components with the same pattern - Fix in bulk and commit
This "establish pattern, apply horizontally" flow was especially efficient given a project with 211 components. When you find one bug pattern, search for other components with the same bug — this habit alone dramatically reduces bug recurrence rates.
Checklist
When creating a new Canvas component, I verify the following:
- Does
useEffectcleanup release all timers, rAFs, and event listeners? - Is AudioContext lazily initialized with try-catch protection?
- Are there no mutable
letvariables at module scope? - Do numerical calculations have
Number.isFiniteguards? - Is Canvas width/height guaranteed to be non-zero?
- Is there a
mountedRefcheck after async operations complete? - Is only one rAF loop running?
- Do event listeners have
{ passive: true }?
The biggest lesson from building 211 components is that bugs occur as patterns, not individual logic mistakes. If you know the patterns, you can prevent them at the coding stage.