The cover fetch reused maxBodyBytes, the 4 MiB ceiling sized for series pages, so any cover above it was rejected, logged, and retried forever while the Series kept a monogram. Measured against asurascans on 2026-08-17 that is not an edge case: p90 is 4.52 MB and 3 of 25 covers exceed 4 MiB, two of them plain JPEGs rather than the 8.57 MB animated GIF the issue names. Covers now have maxCoverBytes = 10 MiB, separate from the page cap: a cover is one bounded binary asset, the page cap still has 3.5x headroom over measured pages and should not be loosened along with it. 10 MiB is ~18% over the largest cover observed and matches the GitHub and Discord image limits. The format offers no help in picking the number — GIF has no maximum size at all — so docs/research/gif-maximum-byte-size.md records the spec reading, the decoder behaviour, and the live distribution the cap is derived from. Transcoding was rejected: decoding is the OOM path, since Go's image/gif allocates width x height per frame with no dimension guard and a legal 65535^2 GIF would ask for ~4.29 GB on a 1974 MiB swapless host.
19 KiB
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.