On a landing page I built, there was a scroll-driven frame-sequence animation: hundreds of image frames played over a canvas as the user scrolled, like video scrubbing but assembled from separate images. Locally it was flawless. The preloader climbed from 0 to 100 percent, the animation locked to scroll, done. Then I deployed to production, and suddenly the preloader would stick at 100 percent without ever handing the screen over to the animation. Sometimes it hung, sometimes it did not. Exactly the kind of bug I trust the least.
I opened the console in production, and there was the clue:
Uncaught InvalidStateError: Failed to execute 'drawImage' on 'CanvasRenderingContext2D': The image argument is a broken image.
The confusing part: this error showed up intermittently in production but never once locally. My first instinct was a bad one, as usual. I assumed it was a GSAP timing issue, or a canvas that was not ready when the first render fired. I fiddled with the initialization order for half an hour before I realized I was blaming the wrong thing.
The symptom
My preloader worked in a simple way: load every frame, count how many finished, and once the count reached the total frame count, fill the bar to 100 percent and hand off to the animation. In production the bar reached 100 percent, so logically every frame had "finished" loading. But the animation never ran. That was the strange contradiction: the loader said done, the render said broken image.
I checked the Network tab. That is where the culprit surfaced: one .webp frame came back with a 404. Its path had a single mistyped digit in the sequence numbering. Locally the file existed on disk, so it never 404'd. In production the file had never been uploaded under the correct name, so one request out of several hundred failed. One rotten frame, and the whole animation collapsed.
The root cause
This is the part that made me pause. My preloader used an onerror handler on each Image, and that handler called resolve(), not reject(). The intent was good: I did not want a single failed image to hang the entire Promise.all forever. But the side effect was fatal. A 404'd frame still produced an HTMLImageElement object, just an empty one. The preloader treated it as "finished" because its promise resolved, the count went up, and the bar reached 100 percent. The loader had no idea anything was wrong.
Then my render function drew frame after frame onto the canvas. The only guard I had was a truthiness check: if the image object exists, draw it. The problem is that a broken Image is still truthy. Even more misleading:
console.log(brokenImg.complete); // true
console.log(brokenImg.naturalWidth); // 0So complete is true as if the image had finished loading, while naturalWidth is zero because there are no pixels at all. My if (img) check passed, ctx.drawImage(img, ...) was called with an empty image, and the browser threw InvalidStateError. That throw propagated up through the GSAP matchMedia callback into the loader, which caught it but could not recover the state, so the handoff never happened and the preloader froze at 100 percent.
Why only in production? Because only production had a 404'd frame. Locally every frame was real, naturalWidth was always above zero, and drawImage never had a reason to throw. The bug was always in my code; local just never triggered it.
The fix
The fix was not to change onerror to reject — that would let one rotten frame hang the whole loader instead. The fix was to stop trusting the image object's truthiness and start checking whether it is actually drawable. I added one small guard:
function isImageReady(img) {
return img && img.complete && img.naturalWidth > 0;
}Then I gated every drawImage call behind it:
if (isImageReady(img)) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
}Now a broken frame is silently skipped. The canvas simply keeps the last valid frame for that index, and since the animation's visibility is already gated by autoAlpha, a single missing frame is invisible to the eye. No more throw, no more frozen loader.
I also deferred any render() call out of the matchMedia initialization. Previously the first render fired during animation setup, so a bad image could crash the entire setup before the user even scrolled. By deferring the initial render until there is interaction, a single failed frame can never again blow up mid-init.
And of course, I fixed the mistyped path so the frame no longer 404s. But the important thing: the site no longer collapses even if a frame genuinely goes missing one day.
Checklist
If drawImage throws InvalidStateError only in production, here is what I check:
- Look in the Network tab for image requests returning
404or403. One rotten frame is enough to topple the sequence. - Remember that a broken
Imageis still truthy andcompleteis stilltrue; an object truthiness check is not enough. - Always gate
drawImagebehind a guard likeimg && img.complete && img.naturalWidth > 0. - Be wary if your preloader's
onerrorcallsresolve(); it hides failed frames as "finished". - Defer the first render out of the animation setup callback so one bad image cannot blow up before interaction.
This bug taught me again that "exists" and "is usable" are two different things. An image object can stand in memory, pass every truthiness check, and still not have a single pixel to draw. Since then, the naturalWidth > 0 guard is the first thing I write whenever I touch a canvas.
