[BUG] Individual sessions degrade 10-30x in latency while concurrent sessions on the same project, version and model stay normal
This report has been substantially rewritten. The original version attributed the slowdown to a regression in image handling introduced around 2.1.252. Further testing disproved that: downgrading to 2.1.251 did not help (it got slower), and sessions carrying more and larger images than the affected one run at normal speed. The correct finding is below — the degradation is per-session, and every structural explanation I tried is ruled out by a direct counterexample. The measurement method and script are unchanged.
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?
Summary
An individual session can degrade to 10–30x its normal latency and stay that way for its whole lifetime, while other sessions running at the same time, on the same project, version, model and machine, remain completely normal. The degraded session never recovers, including across --resume.
The affected session (S69) averaged 175s per turn; a session in the same project an hour earlier averaged 10 ms/tok. This is not context size, not images, not session age, not CLI version, and not a server-wide slowdown — each of those is ruled out by a counterexample below.
Decisive evidence: concurrent sessions, same hour
S69 is the affected session. All measurements are ms per output token, so generation length is controlled for. Every session below ran on the same machine, same account, same CLI build, same model:
09-02 11 S03=16 (n=38, project-3) | S69=118 (n=26, project-14) | S40=18 (n=22, project-7) | S67=10 (n=13, project-14)
09-02 13 S03=17 (n=47, project-3) | S84=11 (n=36, project-1) | S69=225 (n=22, project-14)
09-02 14 S03=22 (n=33, project-3) | S69=322 (n=20, project-14) | S84=16 (n=9, project-1)
09-02 15 S03=26 (n=45, project-3) | S69=310 (n=13, project-14) | S84=13 (n=8, project-1)
Note S67 — a different session in the same project (project-14) as S69, in the same hour, at 10 ms/tok. Whatever is wrong is scoped to the session, not the project, the config, the machine, or the backend at that moment.
Every structural explanation has a counterexample
Ranking all sessions with >=40 turns on the same model:
ms/tok turns imgs imgMB peakCtx ageH version session
137.0 179 10 4.4 336,267 16.7 2.1.258 S69 <- affected
85.0 233 0 0.0 386,105 3.8 2.1.233 S04
38.0 604 1 0.3 566,874 21.9 2.1.246 S53
30.8 289 17 5.9 557,319 7.8 2.1.250 S41
27.2 619 0 0.0 562,898 99.3 2.1.237 S09
10.4 440 1 0.0 419,760 170.6 2.1.237 S46
10.1 162 0 0.0 426,841 2.9 2.1.234 S54
| Hypothesis | Counterexample |
|---|---|
| Large context | S46: 420k peak context, 10.4 ms/tok |
| Images in context | S41: 17 images totalling 5.9MB (vs S69's 10 / 4.4MB), 30.8 ms/tok — 4x faster |
| Session age | S46: 170 hours old, still 10.4 ms/tok |
| Turn count | S09: 619 turns, 27.2 ms/tok |
| CLI version | See downgrade test below |
| Backend-wide slowdown | Concurrent sessions in the same hour are normal |
| Project config (hooks / MCP / CLAUDE.md) | S67, same project, same hour, 10 ms/tok |
Downgrading the CLI does not help
I resumed the affected session on 2.1.251 (a build whose sessions were otherwise healthy). It got worse, tracking the session's own trajectory rather than the version:
2.1.252: n= 69 88.0 ms/tok mean 64.3s mean ctx 110,651
2.1.258: n=102 160.8 ms/tok mean 110.8s mean ctx 240,687
2.1.251: n= 8 248.8 ms/tok mean 221.9s mean ctx 331,468 <- after downgrade
The degradation has a sharp onset
Within the affected session, holding version (2.1.258) and image count (7) fixed:
context n ms/tok mean s
200-240k 16 18.6 14.3
240-270k 16 268.9 146.4
270-999k 23 344.4 175.5
18.6 -> 268.9 ms/tok across a 25% change in context. For comparison, pooled across all my sessions the same model goes from 16.9 ms/tok (0–50k) to only 29.8 ms/tok (400–600k) — a gentle slope with no cliff. So the cliff is a property of this session, not of context size in general.
Prompt cache is healthy — this is not #90716
#90716 (image eviction rewriting the conversation prefix) shows cache_read collapsing to a small constant with cache_creation at full context. Not the case here:
turns: 179 turns with cache_creation > 50k: 2 (1.1%)
mean cache_read: 190,649 mean cache_creation: 3,968
promptTotal is monotonically increasing throughout — the prefix is not being rewritten.
Relation to #71770
#71770 reports the same shape — "new session is fast (~10s first byte), same network, same model, same MCP config; worsens over session lifetime; not correlated with context usage" — and was closed with no comment or explanation. This report adds the control that one was missing: concurrent healthy sessions on the same machine at the same moment, which rules out the backend-load reading its author suspected.
Reproduction
I do not have a deterministic trigger, which is the main gap here. What I can say: the onset was sharp, mid-session, and permanent, and it survived a CLI downgrade and multiple resumes. Opening a fresh session in the same project restored normal latency immediately.
This script identifies the pattern from any user's own transcripts. It prints anonymous session ids only — no paths, project names, or account data:
<details>
<summary><code>probe.py</code></summary>
#!/usr/bin/env python3
"""Detect per-session latency degradation in Claude Code transcripts.
Prints two tables:
1. Every session ranked by latency per output token.
2. Hour-by-hour comparison of sessions that ran concurrently.
Session and project names are replaced with anonymous ids.
Usage: python3 probe.py [~/.claude/projects]
"""
import json, os, glob, sys
from collections import defaultdict
from datetime import datetime
root = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/.claude/projects")
def parse_ts(s):
try: return datetime.fromisoformat(s.replace("Z", "+00:00"))
except Exception: return None
rows = []
for f in glob.glob(os.path.join(root, "*", "*.jsonl")):
proj = os.path.basename(os.path.dirname(f))
prev = prev_type = None
imgs = img_bytes = 0
t0 = None
for line in open(f, errors="ignore"):
try: r = json.loads(line)
except Exception: continue
t = parse_ts(r.get("timestamp", ""))
if t is not None:
if t0 is None: t0 = t
# An 'attachment' record immediately precedes the model's first
# output of a turn, so this delta is TTFT + generation.
if r.get("type") == "assistant" and prev is not None and prev_type == "attachment":
m = r.get("message") or {}
u = m.get("usage") or {}
ctx = (u.get("input_tokens", 0) + u.get("cache_read_input_tokens", 0)
+ u.get("cache_creation_input_tokens", 0))
out = u.get("output_tokens", 0)
dt = (t - prev).total_seconds()
if 0 < dt < 900 and ctx > 0 and out > 0:
rows.append({"dt": dt, "out": out, "ctx": ctx, "imgs": imgs,
"img_mb": img_bytes / 1048576, "age_h": (t - t0).total_seconds() / 3600,
"proj": proj, "sess": f, "model": m.get("model", "?"),
"ver": r.get("version", "?"), "hour": t.strftime("%m-%d %H"),
"cr": u.get("cache_read_input_tokens", 0),
"cc": u.get("cache_creation_input_tokens", 0)})
prev, prev_type = t, r.get("type")
c = (r.get("message") or {}).get("content")
if isinstance(c, list):
for b in c:
if isinstance(b, dict) and b.get("type") == "tool_result" and isinstance(b.get("content"), list):
for x in b["content"]:
if isinstance(x, dict) and x.get("type") == "image":
imgs += 1
img_bytes += len(x.get("source", {}).get("data", ""))
if not rows:
sys.exit("no turns found")
PM = {p: f"project-{i+1}" for i, p in enumerate(sorted({r["proj"] for r in rows}))}
SM = {s: f"S{i+1:02d}" for i, s in enumerate(sorted({r["sess"] for r in rows}))}
for r in rows:
r["P"], r["S"] = PM[r["proj"]], SM[r["sess"]]
def ms(v): return sum(r["dt"] for r in v) / sum(r["out"] for r in v) * 1000
by_sess = defaultdict(list)
for r in rows: by_sess[r["S"]].append(r)
print("Sessions ranked by latency per output token (>=40 turns)")
print(f"{'ms/tok':>8} {'turns':>6} {'imgs':>5} {'imgMB':>6} {'peakCtx':>9} {'ageH':>7} {'version':>9} session")
ranked = []
for s, v in by_sess.items():
if len(v) >= 40:
ranked.append((ms(v), len(v), max(r["imgs"] for r in v), max(r["img_mb"] for r in v),
max(r["ctx"] for r in v), max(r["age_h"] for r in v),
max(set(r["ver"] for r in v), key=lambda x: sum(1 for r in v if r["ver"] == x)), s))
ranked.sort(reverse=True)
for a in ranked:
print(f"{a[0]:8.1f} {a[1]:6} {a[2]:5} {a[3]:6.1f} {a[4]:9,} {a[5]:7.1f} {a[6]:>9} {a[7]}")
print("\nConcurrent sessions, by hour (only hours with 2+ active sessions)")
by_hour = defaultdict(list)
for r in rows: by_hour[r["hour"]].append(r)
for h in sorted(by_hour):
bs = defaultdict(list)
for r in by_hour[h]: bs[r["S"]].append(r)
parts = [f"{s}={ms(g):.0f} (n={len(g)}, {g[0]['P']})"
for s, g in sorted(bs.items(), key=lambda x: -len(x[1])) if len(g) >= 5]
if len(parts) >= 2:
print(f" {h} " + " | ".join(parts))
</details>
If a degraded session's turns can be correlated server-side with a request id, that would probably be more informative than anything further I can measure from the client.
What Should Happen?
A session's latency should not permanently diverge by 10–30x from concurrent sessions doing comparable work. Failing that, it should be observable: right now the only signal is the wall clock, and the only remedy anyone can find is "start a new session", which costs the accumulated context.
Environment
- Claude Code 2.1.258 (also reproduced on 2.1.251 and 2.1.252)
- macOS, Apple Silicon
- Model:
claude-opus-5[1m] - Measurements from local
~/.claude/projects/*.jsonltranscripts, 13k+ turns
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗