Your GIF exporter is fine - the container is the problem
DEV Community

Your GIF exporter is fine - the container is the problem

I spent a week assuming my GIF exporter was weak. A 4.5-second screen recording of an admin dashboard, 960×600, 75 frames, came out at 1.51 MB. I re-encoded it with every setting I could find and hit a wall at 38% saved. Then I stopped guessing and parsed the container byte by byte, and the wall turned out to be structural: a large part of that file is fixed cost that no encoder can touch. About the samples first, because the numbers only mean something if you know what they measured. I did not want to test on someone else's reaction GIFs or video clips, so I drew four animations frame by frame with a script, modelled on the kind of admin UI you see every day: a dashboard recording with a moving cursor and a ticking clock, a full-screen smooth gradient, one file I had already squeezed once, and a KPI dashboard demo. All of the content is invented. I drew each frame at 2× and downscaled with Lanczos, because a real screen capture is retina capture plus downscale - without that step the frames are flat color blocks and the GIF comes out unrealistically small (1.06 MB for the same clip). The timeline is quantised before you touch anything GIF89a is a header followed by a stream of blocks. 0x21 starts an extension, 0x2C starts an image descriptor, and every payload is a chain of sub-blocks terminated by a zero-length byte. The first thing I pulled out was not size at all, it was timing: the graphic control extension stores a frame delay in hundredths of a second. raw = struct.unpack_from(' 60 ms, and nothing in between Every frame of my recording reads back as 6 cs, so 60 ms, 4500 ms in total. Which also means a 66 ms frame is not representable: it lands on 60 ms, because GIF delays are always multiples of 10 ms. Then I walked the image descriptors for the rectangle each frame redraws and for whether it carries its own palette, and those two numbers are what decide whether a GIF has any room left. Cost one: the palette is billed per frame Everyone knows a GIF frame is capped at 256 colors. The part that gets skipped is that every frame may also carry its own local color table, and most encoders write one for each frame to protect quality. In my recording, 74 of 75 frames carry a 256-color table. That is 256 × 3 = 768 bytes each, 56,832 bytes in total, right around 57 KB - unrelated to what is on screen, untouchable by any quality slider. The only two levers are fewer colors and fewer frames. The sample I had already reduced to 64 colors spends 7,104 bytes on its 37 tables. Dropping frames pays twice, which is why it is the first thing I reach for now: every second frame saved 28.8%, every third frame saved 42.5%, and the surviving frames are pixel-identical. The catch is that the total duration shrinks with them - frames get deleted, per-frame delays do not grow to compensate, so the animation quietly plays at double speed until you put the timing back. Cost two: the dirty region is one rectangle GIF does have interframe differencing: a frame may declare a single rectangle, and playback only refreshes that area. I expected this to be a huge win for UI recordings, where most pixels sit still for dozens of frames. 68 of the 75 frames do use partial refresh, and the dirty region still averages 59.0% of the canvas. One rectangle has to enclose every changed pixel in that frame. The cursor moves on the left, the clock ticks in the top right corner, and the untouched table between them gets re-encoded along for the ride. The KPI demo, with numbers, bars and a pie animating in different corners, sits at 90.5% - differencing there is effectively off. H.264 does the opposite: it decides per macroblock, so the still region in the middle can cost nothing at all. Cost three: LZW only rewards horizontal repetition LZW scans pixel runs row by row and saves bytes when it has seen a run before. Flat areas and horizontally repeating graphics compress well; anything busy does not. The gradient sample makes this brutal - zero of its 30 frames can do a partial refresh, and it spends 0.176 bytes per pixel per frame against 0.037 for the recording, a factor of 4.8. The counter-intuitive part is dithering. Reducing colors on a gradient produces banding, and the usual fix is to dither it away. On that same gradient, 64 colors without dithering is 363,719 bytes; with dithering it is 1,004,544 bytes - 2.76× larger, and 69% larger than 256 colors undithered. Counting runs of identical color along one pixel row: 20 runs undithered, 245 with dithering. Dithering turns smooth areas into noise and LZW has nothing left to repeat. On GIF, fixing banding with dithering means giving up compression entirely. What the three costs add up to I encoded the same 75 source PNG frames with local ffmpeg at H.264 default (CRF 23): 82,628 bytes, against 1,585,112 for the GIF. 19.2×. That ratio holds for this sample and these parameters only - it is a mostly-static UI capture, the best possible case for interframe prediction, and fast-moving footage would narrow the gap a lot. For the four-format comparison I used the animation workshop at ImgIng (https://imging.ai/ ). I picked it because demuxing and encoding both run locally in the browser: I kept the Network panel open across a dozen export rounds and counted zero non-GET requests, so the samples never left the machine. Note that the UI text inside my sample frames is Chinese - that is just what I drew. Re-encoded GIF 960 KB, animated WebP 1021 KB, APNG 1.33 MB (pixel-identical, genuinely lossless), animated AVIF 154 KB. Across all sixteen outputs the frame count, per-frame delays, total duration and infinite-loop flag matched the source exactly. The sharpest result came from the file I had already squeezed. Re-compressing it as GIF took 357,648 bytes to 356,964 - pixel-identical, essentially zero saved. The same file as AVIF saved another 62%. So "this GIF won't compress any further" needs a format qualifier: not within GIF, but the encoder still has room. One sample, but it lines up with the three costs above. I have not switched everything to AVIF myself. I only verified decoding and playback in three desktop engines; WeChat, older devices and in-app WebViews are all untested here, and the AVIF output carries no alpha channel. So my order is now: drop frames first, then change format, and only touch dimensions last - scaling to 70% cuts half the pixels and saves 12.3%, while the small table text is already going soft. If you have a GIF that won't shrink, count two things before reaching for another tool: how many frames carry their own palette, and how large the declared rectangle is per frame. Those two numbers usually answer the question. Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.