My GIF re-encode just bloated a file by 450% and it taught me a
If a re-encode inflates the file this drastically, it means the encoder isn't just failing to compress; it is actively throwing away the optimization intelligence present in the source.
Why your encoder is killing your file size
To understand this, you have to look at how an optimized GIF actually functions. A GIF isn't just a stack of full images. A smart encoder writes the first frame as a complete image, but for every subsequent frame, it only writes the pixels that actually changed. It sets the "disposal method" to "do not dispose," meaning the decoder keeps the previous frame on the canvas and simply paints the new, tiny layer of changed pixels on top.
In the pendulum example, the background is a static, dark rig. Only a few percent of the pixels (the swinging bob) are moving. The original source was incredibly efficient because it only "paid" for the bob in every frame.
My encoder, however, was being too "correct." It was writing every single frame as a full-canvas, fully opaque keyframe. Even though the animation looked perfect, I was forcing the user to re-download the static background hundreds of times.
Implementing interframe differencing
To fix this, I had to implement a delta-based approach. For opaque sources, we now write frames as the previous frame plus only the changed pixels. We use a transparent palette index for the unchanged areas and set the disposal to "do not dispose."
Here is the logic I used to identify the delta mask:
function changedPixels(cur, prev) {
const count = cur.length / 4;
const mask = new Uint8Array(count);
let changed = 0;
for (let p = 0, i = 0; p < count; i += 4, p++) {
if (cur[i] !== prev[i] || cur[i + 1] !== prev[i + 1] || cur[i + 2] !== prev[i + 2]) {
mask[p] = 1;
changed++;
}
}
return { mask, changed };
}Note that this specific implementation is for RGB-only sources where alpha is already 255. If the source has actual transparency, we have to stick to the full keyframe path because the transparent index is already "spoken for" in the palette.
Because I am using gifenc, which hard-codes the image descriptor to x=0, y=0, I can't write sub-rectangles at specific offsets like some other encoders. Instead, the optimization relies entirely on LZW compression. LZW excels at collapsing long runs of identical values. By filling the "unchanged" areas with a single transparent index, we create massive runs that LZW can squash into almost nothing.
The failure of pixel-count heuristics
I initially tried to use a simple threshold to decide whether to use a delta or a keyframe: "If more than X% of pixels changed, just write a full keyframe."
It failed miserably. My testing showed:
- 13% pixel change: Resulted in 74% of the size (Delta won)
- 26% pixel change: Resulted in 112% of the size (Keyframe won)
Wait, the math is backwards. The file that changed twice as much was actually smaller when using deltas, and the one that changed less was heavier. This happens because LZW doesn't care about the count of pixels; it cares about how scattered they are. A few pixels scattered randomly across the screen are much more expensive to encode than a large, solid block of changed pixels.
Instead of guessing with a threshold, the encoder now takes three sample frames, encodes them both ways (delta vs. keyframe), and picks the winner.
A technical trap for the implementation
If you are building your own tool, watch out for memory management when packing these changed pixels. When you prepare the buffer for quantization, you absolutely must use a fresh allocation. Never use a subarray view.
const out = new Uint8ClampedArray(changed * 4);If you pass a view into functions like quantize() or applyPalette(), the underlying logic might expect a specific buffer length or behavior that a view won't provide, leading to silent corruption or massive overhead.