PDF page images add ~190KB/page to every subsequent request but are near-invisible in the context meter
What happened
Reading a multi-page PDF injects one base64 JPEG per page into the conversation (~190 KB of base64 each). These images stay in the message history and are re-sent with every subsequent request, but they are effectively invisible in the context meter because their token cost is small relative to their byte cost.
A session of mine became permanently unusable at 78,859 tokens — roughly 5% of a 1M context window — because a single 8-page PDF read had added ~1.5 MB of base64 to every request.
The numbers
One Read call with pages: "1-8" produced:
| | |
|---|---|
| images emitted | 8 (JPEG, 850x1100 px each) |
| base64 added | 1.49 MB |
| token cost | ~10,000 (~1,250/image) |
| total request payload after | 1.61 MB |
| of which images | 93% |
| context meter showed | 78,859 tokens |
| effective density | 20.4 bytes/token (normal text is ~6) |
The last successful turn was the one immediately preceding the image insertion. Every request after it failed. Nothing else about the session changed.
Why it matters
The context meter is the only signal a user has for "is this session getting too heavy," and it measures the wrong dimension for image content. A user can wedge a session at a small fraction of its context limit with no warning and no indication of the cause. From the user's side it presents as "this conversation randomly stopped working."
Recovery is also awkward: re-running the read to get the content back regenerates the same images and re-breaks the session. The workaround is to extract the PDF text separately and read that instead (~10K tokens of text vs 1.5 MB of images).
Reproduction
- Read any multi-page PDF via the
Readtool with apagesrange. - Compare the serialized request payload size against the reported context usage.
Payload grows ~190 KB per page while the context meter barely moves.
Suggested fixes
Any of:
- Account for image byte payload in the context/usage display, not just tokens.
- Warn when a single tool result adds more than some threshold of payload.
- Default PDF reads to text extraction, with images opt-in.
- Drop or downsample page images once they fall outside a recency window.
Environment
- Claude Code 2.1.220
- macOS (darwin 25.1.0)
- Model: Opus, 1M context
3 Comments
Hit this too, with a variant that I think widens the scope of the bug in three ways: it is not only the per-page image path, the payload is stored twice, and on Bedrock it surfaces as a
400that names nothing about size.The variant: whole-document read, no
pagesrangeI read two PDFs with plain
Readcalls (nopagesparameter), 1.6 MB and 379 KB on disk. Instead of per-page JPEGs, each became a single base64documentblock:| | |
|---|---|
| PDFs read inline | 2 |
| base64 added to message history | 2.75 MB |
| message-only request payload after | 4.23 MB |
| of which attachments | 65% |
| transcript file size | 8.48 MB |
| context meter at time of failure | 54% |
So the "~190 KB/page" accounting understates it for full-document reads. There is no page cap in that path at all: the whole file goes in at roughly 1.37x its on-disk size and is re-sent every turn.
It is stored twice in the transcript
Each attachment appears in the session
.jsonlin two places: once as thedocumentblock inmessage.content, and again in a top-leveltoolUseResultfield on the preceding row. Two attachments produced four multi-MB rows. That is the same duplication reported for image tool_results in #80175, so it is not specific to screenshots.The failure signature is misleading
Every request after the ceiling was crossed returned:
That is a Bedrock edge rejection (tool IDs are
toolu_bdrk_*), returned pre-inference in about 1.5 seconds. It says nothing about size, tokens, or attachments, so there is no way to connect it to aReadcall. It also fires on any subsequent message, including asking "why the api error", which makes the session look corrupted rather than oversized.The delay is the reason it is invisible
7 hours 31 minutes and roughly 100 turns passed between the PDF reads and the first failure. The reads themselves succeeded and the work continued normally. By the time anything broke, the cause was far out of view, and the context meter said 54%. Agreed with your framing: from the user's side this presents as a conversation that randomly stopped working.
Recovery is possible, which points at a fix
Rather than losing the session, I edited the
.jsonl: replaced the twodocumentblocks with a one-line text placeholder and blanked the matchingtoolUseResultfields, leaving every other row byte-identical.| | before | after |
|---|---|---|
| message payload | 4.23 MB | 1.48 MB |
| transcript file | 8.48 MB | 2.98 MB |
All 944 rows kept,
tool_use/tool_resultpairing intact (0 unmatched),parentUuidchain intact (0 broken), and--resumethen worked with the full conversation history available. The trailing400rows are flaggedisApiErrorMessage: trueand are filtered on resume, so they did not need removing.Two things follow from that. First, dropping stale attachment blocks is safe and sufficient, which supports your last suggested fix. Second, since hand-editing a transcript restores a session cleanly, a built-in recovery path would help: either a command to strip attachments from the current session, or doing it automatically when a request exceeds the provider ceiling instead of failing. Worth noting that the two feature requests for selective image removal (#52224, #37461) were both closed without shipping, and #74758 and #83206 are the same wedge on the resume path.
Environment
The PDF page images and full-document blocks described here are both patterns Context Offload scans for. It's a free local Skill that creates a clean-session handoff without uploading, printing Base64, or modifying the transcript: Context Offload.
If you try it, candidate count plus Claude Code version is enough; please keep the transcript private. (Disclosure: I maintain it.)
A fresh Windows/VS Code reproduction shows the same failure mode, but with a larger single
Readresult and a hardcontext_length_exceededloop rather than a 20 MB transport error.Reproduction measured from the session JSONL
pages: "1-15"Immediately after that result, the next model request failed with:
Every later
/continuefailed identically./compactalso failed to produce a summary because the compaction request itself inherited the oversized context. This repeated overnight until the session was manually continued through a fresh compacted handoff.Why the existing settings did not protect the session
The live settings declared:
Those are token-window controls, not a cap on a single multimodal tool result.
Readexposes a page-count limit (pages, maximum 20), but no byte budget or text-only mode. Asking for 15 pages is therefore schema-valid while still injecting a 4.39 MB inline result. The failure happened before auto-compaction could recover, and the context meter/settings gave no actionable warning.This supports all four proposed fixes in the issue, especially a pre-injection byte budget for multimodal tool results. A safe behavior would be to stop or split before the result is committed to history, persist the oversized payload out of context, and return a textual pointer/summary. A PDF
mode: "text" | "visual"would also prevent this for text-first research reads.Related: #80449 (text-only PDF mode), #26018 (payload preflight), and #13480 (poisoned image history; reportedly fixed, but
/compactwas not a viable recovery here).