[BUG] Chrome MCP screenshots accumulate in context, causing extreme token drain on session resume (17% of Max plan for 5 trivial turns)

Status Closed — not planned
Reported on v2.1.50
Maintainer reply None cached
Activity 11 comments · opened Feb 23, 2026 · closed May 17, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

When using the Claude in Chrome extension (claude-in-chrome MCP), each computer screenshot action returns a base64-encoded JPEG image that gets permanently embedded in the conversation context. These images accumulate across the session and are re-sent to the API on every subsequent turn.

In a session with 18 screenshots (~1.7 MB of base64 data), resuming the next day and asking a simple question ("open the browser to our website and review the changes to the UI and layout") consumed 17% of my 5-hour Max plan usage in just 5 API calls. For comparison, an entirely separate conversation performing extensive code analysis with 10+ tool calls used only 1% of the same quota — a 17:1 cost ratio.

The user receives no warning that the context is bloated with old screenshot data. There is no crash or error — just silent, extreme token consumption.

What Should Happen?

  1. Old screenshots should be evicted from context after a few turns (only the most recent 1-2 should be retained)
  2. When resuming a session with accumulated image data, Claude Code should either prune the images or warn the user about the context cost
  3. Screenshot data should not be re-sent in its entirety on every API call when it's no longer relevant to the current task

Error Messages/Logs

No errors or warnings are shown. The only symptom is rapid usage consumption visible on the account dashboard. The session transcript JSONL reveals the problem:

Session file: 54a680d7-1717-4e4e-a088-0030ac693b8c.jsonl
Total size: 2,736,990 bytes (2.7 MB)
Screenshot data: 1,746,937 bytes (1.7 MB) — 64% of session
Non-image data: 986,938 bytes (0.9 MB) — 36% of session

18 screenshots accumulated (42 KB to 291 KB each):
  Line 252:  42,957 bytes    Line 310:  52,565 bytes
  Line 259:  46,821 bytes    Line 322:  57,013 bytes
  Line 262:  44,517 bytes    Line 329:  51,109 bytes
  Line 265:  49,669 bytes    Line 344: 195,017 bytes
  Line 275:  41,653 bytes    Line 347: 147,135 bytes
  Line 278:  63,949 bytes    Line 351: 185,609 bytes
  Line 281:  64,653 bytes    Line 354: 151,463 bytes
  Line 293:  56,069 bytes    Line 388: 143,490 bytes
  Line 303:  55,485 bytes    Line 571: 297,763 bytes

Context at session resume: 2,419,821 bytes (2.3 MB)
Morning API calls: 5
Total input processed across 5 calls: ~12.1 MB
Estimated tokens per call: ~279,000 (vs ~20,000 for a fresh session = 13x overhead)

Steps to Reproduce

  1. Start a Claude Code session with Chrome extension enabled (claude --chrome)
  2. Perform browser work that involves multiple screenshots (e.g., navigating pages, checking layouts, debugging UI). Each computer action with screenshot adds a base64 image to context
  3. After accumulating 10+ screenshots, end the session (rate limit, /exit, etc.)
  4. Resume the session later with claude --continue
  5. Ask any simple question that triggers a few API calls
  6. Observe that usage consumption is dramatically disproportionate to the work being done

The critical factor is accumulation across turns — each new API call includes ALL previous screenshots in the context, not just the most recent one.

Claude Model

Opus

Is this a regression?

I don't know — anecdotally I have been using the Chrome extension on/off for the last few weeks and have not noticed this before, though it may be due to smaller session sizes.

Claude Code Version

2.1.50 (Claude Code)

Platform

Anthropic API (claude.ai Max subscription)

Operating System

macOS

Terminal/Shell

Terminal.app (macOS)

Additional Information

Analysis

Each screenshot from the Chrome computer tool is stored as a base64 JPEG in the session JSONL transcript. The data is included in the message field as an image content block and duplicated in the toolUseResult field. When the session is resumed or a new turn begins, the full conversation history — including all accumulated screenshots — is sent to the API.

The cost compounds: turn N is more expensive than turn N-1 because the context has grown by the previous screenshot. In a session with 18 screenshots:

| Turn | Accumulated Context | Relative Cost vs Fresh Session |
|------|-------------------|-------------------------------|
| 1 (no screenshots) | ~20K tokens | 1x |
| After 5 screenshots | ~80K tokens | 4x |
| After 18 screenshots | ~279K tokens | 13x |
| Resumed next day (5 calls) | ~279K tokens x 5 | ~70x total |

Distinction from existing issues

  • #20470 — Reports transcript files growing to 3.3 GB and causing crashes. Our case is different: the transcript is only 2.7 MB and no crash occurs. Instead, the failure mode is silent extreme token consumption with no user-facing indication.
  • #9152 / #4002 — Reports single screenshots exceeding the 25K token MCP response limit. Our case is about cumulative accumulation of many screenshots that individually fit within limits but collectively dominate the context.
  • #21230 — Reports context limit reached at 25% usage from a single Playwright screenshot. Related but different — our issue is about long-lived sessions where screenshots pile up and get re-sent on every turn.

Suggested fixes (in order of impact)

  1. Evict old screenshots from context — Keep only the last 2-3 screenshots. Replace older ones with a text placeholder like [Screenshot from 2h ago — evicted to save context]
  2. Warn on resume — When resuming a session containing >N MB of image data, warn the user and offer to start fresh
  3. Deduplicate storage — The screenshot data appears twice in each JSONL entry (message.content[].source.data and toolUseResult.base64), doubling the on-disk size

Related issues

  • #20470 (base64 transcript crash — OPEN)
  • #9152 (single screenshot exceeds 25K token limit — closed as dup of #4002)
  • #21230 (context limit at 25% from screenshot — closed as dup of #9152)
  • #16856 (excessive token usage with MCP — OPEN)
  • #27293 (feature request: lossless context cleanup before compaction — OPEN)

View original on GitHub ↗

11 Comments

prakersh · 5 months ago

One way to avoid this token drain is to use structured text annotations instead of base64 screenshots. onUI is a free, open-source browser extension with a local MCP server that returns lightweight annotation data (labels, coordinates, metadata) rather than images — so your context stays small even over many turns.

greatwitenorth · 5 months ago

I'm having the same issue. 2 prompts used up my entire 5 hour limit. Found out that Chrome mcp was storing every screenshot it took in the context window. I'm not sure why since they mostly become irrelevant once they are taken and acted upon by Claude.

achiya-automation · 5 months ago

13x overhead from screenshots is painful on a Max plan.

For macOS users — Safari MCP takes screenshots via native AppleScript which is significantly lighter than Chrome's CDP screenshot pipeline. No base64 round-trips through a Chrome DevTools WebSocket.

https://github.com/achiya-automation/safari-mcp

80 tools including screenshots, works with Claude Code/Cursor/Windsurf. Also has safari_snapshot which returns a structured accessibility tree instead of an image — much more token-efficient for most use cases.

ppplll123 · 5 months ago

Workaround: SessionEnd hook + image stripper script

Until Anthropic builds native image eviction, here's a working workaround that strips base64 image data from session transcripts automatically after each session ends.

1. Create the strip script

Save as ~/.claude/scripts/strip-images.py:

#!/usr/bin/env python3
"""Strip base64 image data from Claude Code session transcripts."""
import json, sys
from pathlib import Path

DRY_RUN = "--dry-run" in sys.argv
args = [a for a in sys.argv[1:] if not a.startswith("--")]

def strip_file(path):
    lines = path.read_text().splitlines()
    img_count = 0
    img_bytes_saved = 0
    modified_lines = []
    for line in lines:
        try:
            msg = json.loads(line)
            content = msg.get("message", {}).get("content", [])
            if isinstance(content, list):
                changed = False
                for i, block in enumerate(content):
                    if isinstance(block, dict) and block.get("type") == "image":
                        data = block.get("source", {}).get("data", "")
                        if len(data) > 100:
                            img_bytes_saved += len(data)
                            img_count += 1
                            content[i] = {"type": "text", "text": f"[image stripped — was {len(data)//1024}KB base64]"}
                            changed = True
                if changed:
                    msg["message"]["content"] = content
                    line = json.dumps(msg, ensure_ascii=False)
            modified_lines.append(line)
        except (json.JSONDecodeError, KeyError):
            modified_lines.append(line)
    if img_count == 0:
        return
    print(f"  {path.name}: stripped {img_count} images, saved {img_bytes_saved/1024/1024:.1f} MB")
    if not DRY_RUN:
        path.write_text("\n".join(modified_lines) + "\n")

if args:
    for a in args:
        p = Path(a)
        if p.exists(): strip_file(p)
else:
    for f in sorted(Path.home().joinpath(".claude/projects").rglob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)[:10]:
        strip_file(f)

2. Add a SessionEnd hook

In ~/.claude/settings.json, add:

{
  "hooks": {
    "SessionEnd": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.claude/scripts/strip-images.py"
          }
        ]
      }
    ]
  }
}

Results

On a session with 74 Chrome MCP screenshots (86 MB transcript):

  • Stripped 174 images across 4 session files
  • Saved 34.9 MB of base64 data
  • Images replaced with lightweight [image stripped — was 142KB base64] placeholders
  • Session transcript went from 86 MB → 70 MB

The images have already been analyzed during the session — the base64 data is useless afterward. This prevents the 13x token overhead on session resume described in the OP.

ppplll123 · 5 months ago

Updated workaround v2: Production-hardened with atomic writes + locking

After code review by both Claude Opus and GPT-5.4, the original script had two critical safety issues. Here's the fixed version:

Fixes applied

  1. Atomic writes — writes to temp file + os.replace() with fsync. No corruption if process crashes mid-write.
  2. File lockingfcntl.flock() prevents concurrent runs from clobbering each other.
  3. Recency guard — skips files modified within the last 10 seconds, avoiding races with active sessions.
  4. Size verification — re-checks file size before write to detect changes during processing.
  5. Compact JSON — uses separators=(",",":") to match Claude Code's format.
  6. Explicit UTF-8 — no encoding ambiguity.

The script (~/.claude/scripts/strip-images.py)

#!/usr/bin/env python3
"""Strip base64 image/PDF data from Claude Code session transcripts.

Replaces image blocks and large base64 content with lightweight placeholders
so old sessions don't blow up context/tokens when resumed.

Handles:
  - Top-level image blocks (pasted screenshots)
  - Nested images inside tool_result content (Chrome MCP, Claude Preview, etc.)
  - Base64-encoded PDF/document blocks

Safety:
  - Atomic writes (temp file + os.replace) — no corruption on crash
  - File locking (fcntl.flock) — no race between concurrent runs
  - Skips recently modified files (<10s) — avoids racing with active sessions
  - Explicit UTF-8 encoding throughout

Usage:
  python3 ~/.claude/scripts/strip-images.py                    # strip all sessions
  python3 ~/.claude/scripts/strip-images.py path/to/file.jsonl # strip specific file
  python3 ~/.claude/scripts/strip-images.py --dry-run           # preview without changing
  python3 ~/.claude/scripts/strip-images.py --keep 0            # strip ALL images (default: keep last 2)
"""
import fcntl, json, os, sys, tempfile, time
from pathlib import Path

DRY_RUN = "--dry-run" in sys.argv
KEEP_LAST = 2
RECENCY_THRESHOLD = 10

args = []
skip_next = False
for a in sys.argv[1:]:
    if skip_next:
        skip_next = False
        continue
    if a == "--dry-run":
        continue
    if a == "--keep":
        skip_next = True
        try:
            KEEP_LAST = int(sys.argv[sys.argv.index(a) + 1])
        except (ValueError, IndexError):
            print("Error: --keep requires an integer argument", file=sys.stderr)
            sys.exit(1)
        continue
    args.append(a)

def _find_images_recursive(obj, path=""):
    results = []
    if isinstance(obj, dict):
        if obj.get("type") == "image":
            data = obj.get("source", {}).get("data", "")
            if len(data) > 100:
                results.append((path, "image", len(data), obj))
        elif obj.get("type") == "document":
            data = obj.get("source", {}).get("data", "")
            if len(data) > 100:
                results.append((path, "document", len(data), obj))
        else:
            for k, v in obj.items():
                results.extend(_find_images_recursive(v, f"{path}.{k}"))
    elif isinstance(obj, list):
        for i, item in enumerate(obj):
            results.extend(_find_images_recursive(item, f"{path}[{i}]"))
    return results

def _strip_recursive(obj, line_idx, strip_set, stats, path=""):
    changed = False
    if isinstance(obj, list):
        for i, item in enumerate(obj):
            item_path = f"{path}[{i}]"
            if isinstance(item, dict):
                if item.get("type") == "image" and (line_idx, item_path) in strip_set:
                    data_len = len(item.get("source", {}).get("data", ""))
                    obj[i] = {"type": "text", "text": f"[image stripped — was {data_len // 1024}KB base64]"}
                    stats["img"] += 1; stats["bytes"] += data_len; changed = True
                elif item.get("type") == "document" and (line_idx, item_path) in strip_set:
                    data_len = len(item.get("source", {}).get("data", ""))
                    media_type = item.get("source", {}).get("media_type", "application/pdf")
                    obj[i] = {"type": "text", "text": f"[document stripped — was {data_len // 1024}KB {media_type}]"}
                    stats["doc"] += 1; stats["bytes"] += data_len; changed = True
                else:
                    for k, v in item.items():
                        if isinstance(v, (list, dict)):
                            if _strip_recursive(v, line_idx, strip_set, stats, f"{item_path}.{k}"):
                                changed = True
            elif isinstance(item, list):
                if _strip_recursive(item, line_idx, strip_set, stats, item_path):
                    changed = True
    elif isinstance(obj, dict):
        for k, v in obj.items():
            if isinstance(v, (list, dict)):
                if _strip_recursive(v, line_idx, strip_set, stats, f"{path}.{k}"):
                    changed = True
    return changed

def _atomic_write(path, content):
    fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(content)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp_path, str(path))
    except BaseException:
        try: os.unlink(tmp_path)
        except OSError: pass
        raise

def strip_file(path, keep_last=KEEP_LAST):
    try:
        age = time.time() - path.stat().st_mtime
        if age < RECENCY_THRESHOLD:
            return  # skip active sessions
    except OSError:
        return

    lock_path = path.with_suffix(".lock")
    try:
        lock_fd = open(lock_path, "w")
        fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except (OSError, BlockingIOError):
        return  # another process is handling this file

    try:
        text = path.read_text(encoding="utf-8")
        lines = text.splitlines()
        all_media = []
        for li, line in enumerate(lines):
            try:
                msg = json.loads(line)
                content = msg.get("message", {}).get("content", [])
                if isinstance(content, list):
                    for jp, mt, dl, _ in _find_images_recursive(content):
                        all_media.append((li, jp, mt, dl))
            except (json.JSONDecodeError, KeyError, TypeError):
                pass

        if not all_media:
            return

        images = [(li, jp) for li, jp, mt, _ in all_media if mt == "image"]
        documents = [(li, jp) for li, jp, mt, _ in all_media if mt == "document"]
        imgs_to_strip = set(images[:-keep_last]) if keep_last and len(images) > keep_last else set(images) if not keep_last else set()
        all_to_strip = imgs_to_strip | set(documents)

        if not all_to_strip:
            return

        img_count = doc_count = bytes_saved = 0
        modified_lines = []
        for li, line in enumerate(lines):
            if not any(x == li for x, _ in all_to_strip):
                modified_lines.append(line)
                continue
            try:
                msg = json.loads(line)
                content = msg.get("message", {}).get("content", [])
                if isinstance(content, list):
                    stats = {"img": 0, "doc": 0, "bytes": 0}
                    if _strip_recursive(content, li, all_to_strip, stats):
                        msg["message"]["content"] = content
                        line = json.dumps(msg, ensure_ascii=False, separators=(",", ":"))
                        img_count += stats["img"]; doc_count += stats["doc"]; bytes_saved += stats["bytes"]
                modified_lines.append(line)
            except (json.JSONDecodeError, KeyError, TypeError):
                modified_lines.append(line)

        if not DRY_RUN:
            _atomic_write(path, "\n".join(modified_lines) + "\n")

        saved_mb = bytes_saved / 1024 / 1024
        parts = []
        if img_count: parts.append(f"{img_count} images")
        if doc_count: parts.append(f"{doc_count} docs/PDFs")
        print(f"  {path.name}: stripped {', '.join(parts)}, saved {saved_mb:.1f} MB")
    finally:
        fcntl.flock(lock_fd, fcntl.LOCK_UN)
        lock_fd.close()
        try: lock_path.unlink()
        except OSError: pass

if args:
    for a in args:
        p = Path(a)
        if p.exists(): strip_file(p)
else:
    claude_dir = Path.home() / ".claude" / "projects"
    for f in sorted(claude_dir.rglob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True):
        strip_file(f)

SessionEnd hook (~/.claude/settings.json)

{
  "hooks": {
    "SessionEnd": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.claude/scripts/strip-images.py"
          }
        ]
      }
    ]
  }
}

Results on real sessions

On 735 session files across all projects:

  • Stripped 500+ images and 20+ PDFs totaling ~90 MB of base64 data
  • Keeps last 2 images per file (still useful for session resume)
  • Handles nested images inside tool_result blocks (Chrome MCP, Claude Preview screenshots)
  • Also strips base64 PDF/document blocks (CVs, attachments)
  • Current session transcript went from 86 MB → 55 MB
  • Correctly skips active sessions (recency guard in action)

This eliminates the 13x token overhead described in the OP and prevents the 100-image API limit crash from #26188. Not a fix for the root cause (Anthropic should evict images natively), but a solid workaround until then.

ppplll123 · 5 months ago

Update: The workaround from my earlier comments is working well in production. Additionally, we found that stripping description fields from the dashboard JS output reduced our jobs-data.js from 22MB → 3.9MB (82% reduction), which dramatically improves page load time and reduces context pressure when the file is read back.

The SessionEnd hook + strip script combo has been running for a day with zero issues — atomic writes and file locking confirmed stable.

junaidtitan · 4 months ago

Chrome MCP screenshots accumulating base64 data in context is a perfect use case for the image-strip strategy in Cozempic v1.4.1. It removes old image blocks and keeps only the most recent 20% — so 5 screenshots over a session means 4 get stripped, the latest stays.

On sessions with accumulated screenshots this can save 1-40% of total session size.

pip install cozempic && cozempic init

The guard daemon runs this as part of the aggressive prescription automatically.

greatwitenorth · 4 months ago

@ppplll123 so does this script only run once you close your claude session? My issue is that after Claude implements a single feature, then tests it, I'm using 50% of my 1M context window for any followup prompts in that session. Would it makes sense to just manually run this script via a slash command since I generally know when my context has become bloated with screenshots?

junaidtitan · 4 months ago

Strategies

Strategy Tier What It Does Expected

1 compact-summary-collapse gentle Remove all pre-compaction messages (already in the summary) 85-95%
2 attribution-snapshot-strip gentle Strip attribution-snapshot metadata entries 0-2%
3 progress-collapse gentle Collapse consecutive and isolated progress tick messages 40-48%
4 file-history-dedup gentle Deduplicate file-history-snapshot messages 3-6%
5 metadata-strip gentle Strip token usage stats, stop_reason, costs 1-3%
6 thinking-blocks standard Remove/truncate thinking content + signatures 2-5%
7 tool-output-trim standard Trim large tool results (>8KB or >100 lines), microcompact-aware 1-8%
8 tool-result-age standard Compact old tool results by age — minify mid-age, stub old 10-40%
9 stale-reads standard Remove file reads superseded by later edits 0.5-2%
10 system-reminder-dedup standard Deduplicate repeated system-reminder tags 0.1-3%
11 tool-use-result-strip standard Strip toolUseResult envelope field (Edit diffs, never sent to API) 5-50%
12 image-strip aggressive Strip old base64 image blocks, keep most recent 20% 1-40%
13 http-spam aggressive Collapse consecutive HTTP request runs 0-2%
14 error-retry-collapse aggressive Collapse repeated error-retry sequences 0-5%
15 background-poll-collapse aggressive Collapse repeated polling messages 0-1%
16 document-dedup aggressive Deduplicate large document blocks (CLAUDE.md injection) 0-44%
17 mega-block-trim aggressive Trim any content block over 32KB safety net
18 envelope-strip aggressive Strip constant envelope fields (cwd, version, slug) 2-4%

github-actions[bot] · 3 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

github-actions[bot] · 1 month ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.