Files
mangaBookmark/docs/research/gif-maximum-byte-size.md
2026-08-17 12:06:14 +07:00

19 KiB
Raw Permalink Blame History

GIF — maximum byte size of a file

Research note for Gitea issue #71 (backend maxBodyBytes = 4 MiB rejects the 8,571,192-byte animated cover GIF at https://cdn.asurascans.com/asura-images/covers/a-dragonslayers-peerless-regression.gif).

All facts fetched live on 2026-08-17: the GIF89a spec at https://www.w3.org/Graphics/GIF/spec-gif89a.txt, Go stdlib image/gif sources at /usr/local/go/src/image/gif/reader.go (Go 1.26.5), Chromium blink/renderer/platform/image-decoders/ sources via chromium.googlesource.com, Firefox image/decoders/nsGIFDecoder2.cpp via hg.mozilla.org, and cover bytes probed with plain curl (desktop Chrome UA; HEAD/ranged GET). No Cloudflare challenge was encountered on any CDN probe — every request returned real headers, consistent with the AGENTS.md note of 2026-07-26 that plain curl works against both scan sites from the dev machine and the VPS.

Every claim carries the URL it came from, or a reproducible command. Interpretation rather than observation is marked [INFERENCE].


1. Summary answer table

Question Answer Evidence
Does the GIF89a spec define a maximum file size? No. There is no file-size field anywhere in the format; the only numeric ceilings are per-field (16-bit screen/image dimensions, 255-byte sub-blocks, 12-bit LZW codes). §2
Maximum logical screen 65535 × 65535 pixels (unsigned 16-bit width/height). §2.1
Number of frames / image descriptors Unbounded — "An unlimited number of images may be present per Data Stream." §2.2
Formal max byte size of any single GIF None. Single-frame worst case ≈ 6.44 GB (12-bit LZW, max canvas); animated GIFs are unbounded because frames are unbounded. §3
Does the backend's decoder (Go image/gif) bound size? No. It reads 16-bit dimensions and allocates width×height bytes per frame; a 65535² frame forces a ~4 GiB allocation. No total-size or dimension guard. §4.1
Do browsers bound on-disk GIF size? Chromium and Firefox: no on-wire size cap in their GIF readers; Chromium caps decoded memory at min(4 B × pixels, platform budget). §4.3, §4.4
Real cover sizes (asurascans, n=25) min 190,410 B · median 1,275,082 B · p90 4,524,788 B · max 8,571,192 B · 3/25 > 4 MiB (two JPEGs and the animated GIF) §5
Real cover sizes (demonicscans/readermc, n=78) min 13,298 B · median 63,061 B · max 801,200 B · 0/78 > 4 MiB §5
Comparable service caps GitHub: 10 MB for images/GIFs. Discord API: default 10 MiB per file. Wikimedia: 100 MiB upload / 5 GiB host. §6
Recommended cover cap for #71 10 MiB (separate from the 4 MiB series-page cap). Covers 100% of the 103 observed covers; matches GitHub/Discord calibration; ≤ 20 MiB worst-case transient per concurrent fetch+serve on a 1974 MiB swapless VPS. §7

2. What the GIF89a specification actually bounds

Source: https://www.w3.org/Graphics/GIF/spec-gif89a.txt (fetched 2026-08-17).

2.1 Fixed-width fields — the only hard ceilings

The format is a stream of fixed-width blocks; the numeric fields that do have a ceiling are all 16-bit unsigned, little-endian ("multi-byte numeric fields are ordered Least Significant Byte first", §4 of the spec):

  • Logical Screen Width / Height — "Unsigned" 2-byte fields (§18, Logical Screen Descriptor) → maximum 65535 × 65535 pixels.
  • Image Left / Top Position, Image Width / Height — "Unsigned" 2-byte fields (§20, Image Descriptor). Each image "must fit within the boundaries of the Logical Screen" (§20a), so an image cannot exceed the 65535² canvas even though its own fields would allow it.
  • Data sub-blocks — "A data sub-block may contain from 0 to 255 data bytes" (§15); each sub-block is preceded by a 1-byte size field and the stream is terminated by a 0x00 Block Terminator (§16). This bounds a chunk, not the stream.
  • Global/Local Color Tables — optional, "3 x 2^(Size of Global Color Table+1)" bytes with a 3-bit size field → at most 3 × 2⁸ = 768 bytes each (§19, §21).
  • LZW codes — "The output codes are of variable length, starting at +1 bits per code, up to 12 bits per code. This defines a maximum code value of 4095 (0xFFF)" (Appendix F, COMPRESSION, rule 4).
  • Trailer — a single byte, fixed value 0x3B, "indicating the end of the GIF Data Stream" (§27).

2.2 What is unbounded

  • Number of images (frames). §20a, verbatim: "This block is REQUIRED for an image. Exactly one Image Descriptor must be present per image in the Data Stream. An unlimited number of images may be present per Data Stream."
  • The Data Stream itself. The grammar in Appendix B is <GIF Data Stream> ::= Header <Logical Screen> <Data>* Trailer, and the spec states "the entity Data … may be repeated any number of times, including 0 times." There is no field anywhere that carries a file size, byte count, frame count, or total-length value. §13 (Block Sizes) only defines sizes within blocks.

2.3 Verdict

The GIF89a specification defines no maximum file size. The only hard bounds are per-field: 65535×65535 pixels per screen/image, 255 bytes per sub-block, 12 bits per LZW code, and one trailer byte. A compliant decoder must process whatever stream the blocks describe. Any byte ceiling a particular GIF actually hits is therefore implicit — 16-bit dimensions, LZW code width, decoder memory, or an external policy — never something the format itself enforces. [INFERENCE] This is why real-world GIFs cap out at "a few GB at most" and every service that wants a bound has to impose one itself (see §6; Wikimedia explicitly documents that a 4 GiB host limit was a storage-representation artifact of 32-bit integers, phab:T191805, not a format limit).


3. Theoretical worst case

3.1 Single frame, maximal canvas, 8-bit pixels

Quantity Value Derivation
Max pixels 4,294,836,225 65535 × 65535
Raw 8-bit palette-index raster 4,294,836,225 B ≈ 4.29 GB / 4.00 GiB 1 byte per pixel (Table Based Image Data, §22; Go's image.Paletted uses exactly 1 byte/pixel)
LZW worst case ≈ 6.44 GB / 6.00 GiB codes ≤ 12 bits each (Appendix F), at most ~1 code per pixel for incompressible data → ≤ 12 bits/px = 1.5 B/px → 4,294,836,225 × 1.5 B
Sub-block overhead ≈ +25.3 MB every ≤255-byte chunk carries a 1-byte size field (§15): ⌈6,442,254,338 / 255⌉ ≈ 25,263,743 size bytes, + 1 block terminator
Fixed overhead ≈ +1.6 KB header 6 B (§17) + logical screen descriptor 7 B (§18) + global color table ≤ 768 B (§19) + image descriptor 10 B (§20) + local color table ≤ 768 B (§21) + LZW minimum code size 1 B (§22)

So a single maximal-frame GIF cannot exceed ≈ 6.47 GB on the wire (12-bit LZW bound), and LZW being lossless means the real byte count depends entirely on image content — the same canvas can be a few KB (flat color) or ~6 GB (noise).

Two caveats, both marked [INFERENCE]:

  • The "1.5 B/px" figure assumes ~one emitted code per pixel. An encoder is permitted to emit a Clear code at any point (Appendix F: "The Clear code can appear at any point in the image data stream"), so a pathological-but-compliant encoder emitting clear+pixel per pixel reaches ~24 bits/px ≈ 12.9 GB for the max canvas. Real encoders do not do this; 12-bit/px is the practical bound.
  • The spec's deferred-clear note (cover sheet) explicitly allows an encoder to keep using a full table at 12-bit codes without clearing, so the 12-bit cap holds for the whole stream, it cannot "grow" past 12 bits.

3.2 Animated GIFs: unbounded

Every frame is one Image Descriptor, each bounded by the 65535² canvas, but the count of frames is unbounded (§2.2). Total bytes = sum over frames — therefore there is no finite maximum byte size for an animated GIF in the format. The only thing that stops a real one is decoder memory, a service cap, or disk space. [INFERENCE] This is the category the issue #71 cover falls into: it is an animated GIF (NETSCAPE2.0 loop extension found at offset 0x310 of the file, verified 2026-08-17 by a ranged GET), and its 8,571,192 bytes are ~2.04× the current 4 MiB backend cap.


4. Decoder-side real limits

4.1 Go image/gif (the backend's decoder path, stdlib)

Source: /usr/local/go/src/image/gif/reader.go, Go 1.26.5.

  • Dimensions are read as little-endian uint16 — left/top/width/height := int(d.tmp[N]) + int(d.tmp[N+1])<<8 (reader.go:490-493) — so the format ceiling 65535 applies, and nothing smaller is enforced.
  • The only geometric check is that each frame fits inside the logical screen: if left+width > d.width || top+height > d.height → errors.New("gif: frame bounds larger than image bounds") (reader.go:512-513).
  • There is no file-size, byte-count, frame-count, or pixel-count guard. Each frame allocates image.NewPaletted(...) (reader.go:515) — a []byte of width×height — so decoding one legal 65535² frame attempts a ~4.29 GB allocation. DecodeAll (reader.go:603-605) additionally retains every frame's Pix slice for the lifetime of the returned *GIF.
  • [INFERENCE] On the 1974 MiB swapless VPS (root AGENTS.md), decoding such a file would OOM rather than error cleanly; nothing in stdlib protects the process. This matters for §7: the backend stores cover bytes without decoding them (see §5.3), so the fetch path never triggers this — but any future "validate/re-encode server-side" scheme would.
  • Grep for MaxInt|limit|too large|bounds in reader.go: the only hits are the frame-bounds check above and the tmp [1024]byte scratch buffer (reader.go:109); no size caps exist.

4.2 giflib / libgif

Not verified from source. On 2026-08-17 the giflib sources were not reachable from this network: github.com/giflib/giflib returns 404 (repo gone/moved), gitlab.com/giflib/giflib/-/raw/... answers a Cloudflare "Just a moment…" challenge, and the SourceForge project download path 404s. No limit claim about giflib is made here. [INFERENCE] giflib is widely known to be allocation-driven with no dimension cap, but that is not checked against source and is not needed for issue #71 (the backend uses Go stdlib, not giflib).

4.3 Chromium (browser behaviour, first-party source)

  • third_party/blink/renderer/platform/image-decoders/gif/gif_image_reader.cc (via chromium.googlesource.com/chromium/src/+/main/..., fetched 2026-08-17): no GIF byte-size or dimension cap found — grep for max|limit|too large|dimension|65535|overflow matches only license text.
  • The base ImageDecoder caps decoded memory, not transfer size: CalculateMaxDecodedBytes computes min(4 * num_pixels, platform_max_decoded_bytes) (8 bytes/pixel for high-bit-depth), and the header comment says "Ignoring this limit can cause excessive memory use or even crashes on low-memory devices" (image_decoder.cc:94-117, image_decoder.h:545-549). The GIF reader itself is untouched by this — it is a decoded-buffer budget.
  • Practical consequence [INFERENCE]: a browser will happily download and store a multi-GB GIF from its own cache perspective; Chromium only limits what it decodes into pixels.

4.4 Firefox

image/decoders/nsGIFDecoder2.cpp (via hg.mozilla.org/mozilla-central/ raw-file/tip/..., fetched 2026-08-17): no dimension or size limit; the only guards are on LZW code width (MAX_BITS = 12, "maximum codeword size of 12 bits") and the decode stack. Nothing bounds the on-disk byte size.

4.5 Summary

No mainstream decoder enforces a byte-size ceiling; they stop at the 16-bit dimension ceiling (Go, by construction) or at decoded-memory budgets (Chromium) or nowhere (Firefox). A GIF's byte size is policed only by storage policies — which is what §6 calibrates and §7 sets.


5. Practical distribution — what real manga covers weigh

Probed 2026-08-17 with curl -sI (HEAD) and ranged GETs, desktop Chrome UA. No Cloudflare block on any request. Sample = covers as the backend would fetch them (the og:image/page-listed cover URL), not thumbnails we chose by hand.

5.1 Exact commands

# asurascans.com — harvest cover URLs from the homepage, then HEAD each
curl -s -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0" https://asurascans.com/ -o home.html
grep -oE 'https://cdn\.asurascans\.com/asura-images/covers/[^"&\\< ]+\.(webp|gif|jpg|jpeg|png)' home.html \
  | sort -u | grep -v '\-400\.' | head -25 > sample.txt        # one full-res cover per series, no -400 thumbs
while read -r u; do curl -s -A "…Chrome/126.0" -I "$u" | tr -d '\r' \
  | grep -iE '^content-length:'; done < sample.txt

# demonicscans.org — covers live on readermc.org (ADR-0007), URLs contain spaces/UTF-8
curl -s -A "…Chrome/126.0" https://demonicscans.org/ -o demonic.html
grep -oE 'src="https://readermc\.org/images/thumbnails/[^"]+"' demonic.html | tr -d 'src="' > demonic.txt
# …plus og:image from 5 manga pages (Catastrophic-Necromancer, Magic-Emperor, …)
# each URL percent-encoded per path segment (urllib.parse.quote, safe=':/') before HEAD

5.2 asurascans — 25 full-res covers (mixed formats)

Homepage fetched 200 (664,700 B). All 25 returned 200 with a real Content-Length. Distribution:

Statistic Bytes
n 25
min 190,410
median 1,275,082
p90 4,524,788
max 8,571,192
mean 1,943,651
> 4 MiB (4,194,304) 3 (12%) — a-dragonslayers-peerless-regression.gif 8,571,192 (the issue #71 cover, animated: NETSCAPE2.0 at 0x310, 550×733, 256 colors); bad-born-blood.3008f6.webp 4,524,788 image/jpeg; ending-maker.cfbf53.webp 4,619,303 image/jpeg

Notes: the CDN serves Content-Type by stored bytes, not by URL extension (the .webp URLs return image/png, image/jpeg, or image/webp — the sample spans all four of png/jpeg/webp/gif). Two of the three over-cap files are not GIFs, so the current 4 MiB cap already silently drops 12% of asura covers of any format. p90 itself (4.52 MB) exceeds the cap.

5.3 demonicscans — 78 covers on readermc.org

78 unique cover URLs (73 from the homepage's /images/thumbnails/ plus 5 og:image values from manga pages — demonicscans publishes the thumbnail file as the full cover, so that is exactly what the backend would fetch). 78/78 returned 200 with a real Content-Length (spaces and UTF-8 in the filenames were percent-encoded per path segment; the homepage's raw HTML carries ’-style mojibake for curly quotes, which was repaired by latin-1→utf-8 re-encoding before probing).

Statistic Bytes
n 78
min 13,298
median 63,061
p90 206,994
max 801,200
mean 110,038
> 4 MiB 0

5.4 Reading

[INFERENCE] asurascans covers are the heavy tail (median 1.3 MB, top decile > 4 MiB, occasional ~5–9 MB), demonicscans covers are tiny (all < 0.8 MB). A cover cap must be chosen against the asura distribution — the 8.57 MB animated GIF is not a freak one-off outlier; the 90th percentile already crosses 4 MiB and two JPEGs sit between 4.5–4.7 MB.


6. Comparable documented byte caps (first-party docs only)

Service Cap Source (fetched 2026-08-17)
GitHub (issues/PR comments) 10 MB for images and gifs; 25 MB other files; 10/100 MB video https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/attaching-files — "The maximum file size is: 10MB for images and gifs … 25MB for all other files"
Discord (API uploads) default 10 MiB per file, higher with Nitro / boost tier https://discord.com/developers/docs/reference#uploading-files — "The file upload size limit applies to each file in a request. The default limit is 10 MiB for all users" (help-center article support.discord.com/hc/en-us/articles/115002935588 exists but answered 403 from this network on the probe date, so its figures were not verified here)
Wikimedia Commons 100 MiB upload limit; hosting up to 5 GiB; GIF thumbnails limited to 100 megapixels; prior 4 GiB host cap was a 32-bit storage artifact (phab:T191805) https://commons.wikimedia.org/wiki/Commons:Maximum_file_size
MDN nothing — MDN documents no byte-size limit for images; browsers impose none (see §4.3–4.4) [INFERENCE] from absence in the platform docs read in §4

Calibration takeaway: two major platforms independently land on ~10 MB as the ceiling for an uploadable image/GIF (GitHub exactly 10 MB, Discord exactly 10 MiB), with Wikimedia the outlier at 100 MiB/5 GiB because it is a media archive. A 10 MiB cover cap is therefore squarely inside industry normal.


7. Recommendation for issue #71

Raise the cover cap to 10 MiB (10,485,760 B) — as a separate constant, not by moving the shared one.

Why:

  • Fits the measured reality. The largest observed cover is 8,571,192 B (the issue's animated GIF) = 82% of 10 MiB; 10 MiB covers 100% of the 103 sampled covers and the entire asura distribution, including its heavy tail. 4 MiB rejects 12% of asura covers (two of them plain JPEGs).
  • Matches industry calibration (§6): GitHub 10 MB images/GIFs, Discord 10 MiB default. A 10 MiB cap is a number every engineer recognizes, and it leaves ~18% headroom over the current worst observed file.
  • Costs little on the target hardware. The backend buffers cover bytes whole during fetch (backend/internal/latest/cover.go: ContentLength > maxBodyBytes rejection at :155, then io.ReadAll(io.LimitReader(…, maxBodyBytes+1)) at :158) and loads the full body per GET /covers/… (backend/internal/api/handlers.go, Cover → w.Write(body)). Worst case per concurrent fetch + serve is therefore 2 × cap = 20 MiB; even ten of each concurrently is ~200 MiB of a 1974 MiB swapless VPS (~10%), and the browser unit (471 MiB, root AGENTS.md) is no longer on that box. The 4 MiB series-page cap is not the issue — measured pages run 100 KB–1.2 MB (backend/internal/latest/fetch.go comment) — so keep it.
  • The cap is a separate knob. Today one const maxBodyBytes = 4 << 20 (backend/internal/latest/fetch.go:17) gates both series pages and covers (cover.go references it). Raising it wholesale would loosen the page-side memory guard for no benefit; a cover-specific constant (e.g. maxCoverBytes = 10 << 20) keeps the two policies independent. The fetch already double-checks ContentLength and the post-LimitReader length, so a larger constant changes nothing else.

Alternatives and their costs:

Option Cost
Keep 4 MiB 12% of asura covers (incl. non-GIF JPEGs) never stored — current bug, silent missing covers.
16 MiB cap 2× headroom over the observed max for future GIFs; +60% worst-case transient memory vs 10 MiB; diverges from the GitHub/Discord 10 MB calibration.
Server-side re-encode / downscale covers Requires decoding → Go image/gif allocates width×height per frame with no guard (§4.1); a legal 65535² GIF forces a ~4.29 GB allocation on a 1974 MiB swapless box — OOM, not an error. Also mutates bytes, which the store treats as immutable/content-addressed (ADR-0007). Highest risk, no upside at this scale.
No cap Unbounded transient memory and disk; rejected outright.

Decision is the user's; on the evidence, 10 MiB for covers, 4 MiB for pages is the defensible middle.