A Truncated JPEG Broke Every Image After It in Chrome
An image preview would intermittently go blank when stepping through a gallery. I spent a long time inside compositing layers, React render timing and cache behaviour, and fixed none of it. The root cause was two missing bytes at the end of one JPEG.
The detour is the interesting part, so this write-up keeps it.
Symptoms
The gallery is built on rc-image's Image.PreviewGroup: click a thumbnail, a preview layer opens, arrow keys step through a set of captured photos. Reports from production:
- Stepping to the next image intermittently renders blank. The
<img>still has its layout box — a background colour set on it paints — but the image content itself never appears. - Opening DevTools makes the first step more likely to fail; closing DevTools makes the blank recover.
- Disabling the network cache and clicking through quickly raises the failure rate sharply.
- Noticed much later: Chrome fails every time, Firefox never does.
The first three observations pin you to "rendering, compositing, caching". The fourth is the one that actually solves the case, and I got to it far too late.
The dead ends
Every one of these looked reasonable. None of them fixed anything.
The preview uses translate3d and scale3d for pan and zoom, which promotes the <img> to its own compositing layer, with the bitmap uploaded to the GPU as a texture. Stepping through images reuses the same DOM node and only swaps src. So the first hypothesis was a texture-refresh race:
The new bitmap has decoded, but the compositing layer's texture has not been refreshed yet, so the frame paints empty. Toggling DevTools forces the browser to re-commit the compositor tree, effectively re-uploading the texture, which is why closing it recovers.
The corresponding fix is to force React to unmount the old node and mount a new one, so there is no stale texture to survive:
const renderPreviewImage = (imgNode, info) =>
cloneElement(imgNode, { key: info.image?.url });It reduced the failure rate. It did not eliminate it. In hindsight that is the signal: a fix that only reduces a failure usually means the model is wrong, not that the fix needs tuning.
Next hypothesis: the new bitmap is not ready at swap time, so prepare it in advance. First attempt, decode in the background:
const loader = new window.Image();
loader.onload = () => setCommittedSrc(src); // only swap once decoded
loader.src = src;Still blank with the cache disabled and fast clicking. new Image() creates a detached node; it decodes into its own memory, and the <img> that actually renders re-issues its own request when the cache is off. The pre-decode was wasted work.
Calling decode() on the node that would actually render hit two more walls. If it is still detached, a cache-disabled swap re-fetches anyway. And Chrome deliberately skips decode and texture upload for zero-area or opacity: 0 elements as a power optimisation, deferring the work to the moment it becomes visible — blank again.
I also tried disabling the transition, and suspected the one-frame delay introduced by the demo holding currentIndex as controlled state. The transition is unrelated to layer creation (translate3d creates the layer), so it only shifted the odds. The controlled index was an amplifier, not a cause.
| Attempt | Fixed it | Why not |
|---|---|---|
imageRender + fresh key | No | Works around stale texture, does not remove the cause |
new Image() pre-decode | No | Detached node; cache-disabled swap re-fetches |
| DOM double-buffering with key reuse | No | Upstream bitmap is never actually ready |
Staged decode() | No | Chrome skips decode for offscreen elements |
| Disable transition | No | Unrelated to layer creation, only lowers the odds |
Uncontrolled currentIndex | No | Amplifier only |
At that point every "frontend timing" avenue was exhausted, so I went back to the one observation I had been ignoring. If this were plain network or decode latency — a physical bottleneck — both browsers would be slow. Only Chrome failing means Chrome is stricter about some malformed input. That finally moved my attention from the code to the data.
SOI, SOS and EOI
Some background first. A JPEG file is a sequence of segments delimited by two-byte markers, each starting with 0xFF:
| Marker | Bytes | Meaning |
|---|---|---|
| SOI | FF D8 | Start of Image — beginning of file |
| SOS | FF DA | Start of Scan — beginning of entropy-coded data |
| EOI | FF D9 | End of Image — end of file |
A well-formed JPEG begins with FF D8 and ends with FF D9. EOI is how a decoder knows the image data has ended.
The important part is that browser decoders are partially tolerant of a missing EOI. The scan data after SOS is a continuous entropy-coded stream, decoded MCU (minimum coded unit) by MCU as it arrives. If the stream is cut off at the end and only FF D9 is missing, the decoder has usually already produced most or all of the pixels — so the image "looks like it works". Internally it knows the image is incomplete, and that knowledge surfaces later as a delayed error event, a poisoned resource cache, or similar.
Truncated JPEGs are not exotic. Streamed or chunked transfers that get cut, a backend that writes the file without flushing, a CDN or proxy truncating a response, a custom imaging pipeline (frame grabbing, transcoding, stitching) that forgets the terminator — any of these produce a file where everything but the ending is correct.
The root cause is in the bytes
I pulled the two images out and looked at them directly. In the Network panel the bad one showed as a broken-image thumbnail, yet <img> still displayed it — exactly the partial tolerance described above.
xxd on the tail of each file:
# Bad image: all 1300287 bytes arrived, but the file does not end in FF D9
$ xxd 5_4776102593225048067.jpg | tail -1
0013d730: ... d7b5 283d c8a3 70 .K.c.Ru...(=..p <- ends with 70, no FF D9
# Good image: terminates correctly
$ xxd 5_4776098263897997315.jpg | tail -1
0000d6c0: ... fd4b 7ff2 afff .K.... <- ...ff d9The bad file's byte count was complete — 1300287 bytes, short of correct by only the final two — but the terminator was missing. The JPEG stream had been truncated, most likely by the server cutting the stream or writing the file without a flush.
So this was never a rendering problem. It was an incompletely encoded file.
Why one bad image poisons the good ones
This is the counter-intuitive part. The bad image displays acceptably on its own. But after displaying it, stepping back to a known-good image renders blank — and only in Chrome.
The mechanism that is certain: the preview component reuses a single <img> element for performance, swapping src rather than rebuilding the node. The failure therefore has to be element-scoped state that survives a src swap, because rebuilding the node makes it disappear (see the reproduction below).
Which specific Blink state that is, I did not confirm against the Chromium source. These are the candidate mechanisms, and they are hypotheses:
- Delayed error event. Blink may decode the available MCUs and fire
load, then discover the missing EOI at stream end and fireerrorasynchronously. If that lateerrorlands on the same<img>aftersrchas already moved to a good image, the element ends up markedcomplete = true, naturalWidth = 0, and the good image takes the broken-image path and is never painted. ImageResourcecache poisoning. The truncated decode is markedLoadFailed, but the loader the element still holds may not be cancelled cleanly across thesrcswap, stalling the new fetch.- GPU texture slot contamination. After a large image fails to decode, the texture slot is occupied but incomplete, and the dirty flag is not set, so the compositor treats the old texture as still valid and skips the upload. This one lines up neatly with "toggling DevTools recovers it", since that forces a full GPU re-render.
Firefox (Gecko) is more forgiving of truncated JPEGs and cleans up more thoroughly after the error, so the bad image does not affect what comes after it. That is the real explanation for "Chrome always, Firefox never". It is not a Chrome bug so much as Chrome surfacing bad data that Firefox absorbs silently.
Verifying and fixing
The fastest verification is to append the missing terminator and see whether the symptom disappears:
cp 5_4776102593225048067.jpg broken_fixed.jpg
printf '\xff\xd9' >> broken_fixed.jpg # append EOI by hand
xxd broken_fixed.jpg | tail -1 # now ends ...70 ff d9To make the conclusion airtight I built a minimal reproduction page with three paths sharing one log:
- SAME
<img>— one element, swapsrc(mirrors therc-imagereuse path). Sequence good → bad → good: the third step is blank. - NEW
<img>—createElementa fresh element each step: the bad image does not affect anything after it, all steps render. - EOI repaired — replace the bad file with
broken_fixed.jpg: all steps render.
The page also reads the last 32 bytes of each file with fetch(name, { headers: { Range: 'bytes=-32' } }) and asserts that the bad file does not end in FF D9 and the repaired one does. That isolates the failure to the conjunction of element reuse and a missing EOI; only the SAME path with the bad image poisons subsequent images.
The fix is layered:
| Layer | Action | Nature |
|---|---|---|
| Server | Validate integrity before storing (check for a trailing FF D9) and reject files without an EOI | Root fix |
| Component | Listen for onError on <img> and force a remount by changing key, cutting off element-scoped state | Mitigation |
| Component | Validate with a staged <img> decode() before swapping; fall back on reject | Safety net |
| Application | On onError, drop the broken URL from the image list | Degradation |
The root fix belongs on the server. Keep a JPEG without an EOI out of storage and the entire chain of frontend symptoms never happens. Everything the frontend can do is a safety net, and none of it stops a source that keeps producing bad data.
Takeaways
- For intermittent rendering failures, suspect the data before the rendering code. I spent a long time in compositing layers, React timing and caching; the cause was in the image's bytes.
- A fix that mitigates but never eliminates is a signal that the model is wrong.
- Two browsers disagreeing is strong evidence of bad input, not a rendering bug. I weighted that clue far too late.
- Suspect a corrupt JPEG? Look at the last two bytes first:
xxd file.jpg | tail. A JPEG must end inFF D9.
References
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
