# 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 ` ::= Header * 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 ```sh # 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.