[BUG] Individual sessions degrade 10-30x in latency while concurrent sessions on the same project, version and model stay normal

Status Open
Reported on v2.1.252
Maintainer reply None cached
Activity 1 comment · opened Sep 2, 2026
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/*.jsonl transcripts, 13k+ turns

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗