[FEATURE] Compress API request bodies with gzip by default
Preflight Checklist
- [x] I have searched existing requests and this feature hasn't been requested yet
- [x] This is a single feature request (not multiple features)
Problem Statement
Claude Code re-uploads the full conversation context on every API call, uncompressed. In a long session that's 500 KB–1 MB+ of request body per turn, and multi-turn tool use means many turns per prompt. On asymmetric residential uplinks this adds real latency to every request; on slow or metered connections (hotspot, tethering, poor routes) it makes Claude Code borderline unusable — we measured a session pushing hundreds of MB of upload in an afternoon.
The fix is unusually cheap because the server side already exists: api.anthropic.com accepts Content-Encoding: gzip request bodies today. Only the client isn't sending them.
Empirically verified current state (2.1.246, Linux x86_64)
- Request bodies leave Claude Code identity-encoded. Verified by pointing
ANTHROPIC_BASE_URLat a local logging proxy: bodies arrive with noContent-Encoding, e.g. a single/v1/messages?beta=truebody of 594,750 B raw. - The API accepts gzip request bodies right now. Recompressing those same bodies with
Content-Encoding: gzipand forwarding toapi.anthropic.comworks flawlessly (200s, streaming unaffected). - Only gzip.
zstd,br, anddeflaterequest encodings are all rejected with 400 (tested via/v1/messages/count_tokens).
Measured benefit (real traffic, not synthetic)
Numbers from a gzip-compressing local proxy (stdlib Python, gzip level 6) carrying normal interactive Claude Code sessions:
- Real request bodies compress ~2.1×: e.g. 594,750 B → 289,145 B; 330,287 B → 147,483 B.
- One machine, ~3 hours of ordinary use: 185 MB of upload eliminated.
- CPU cost: single-digit milliseconds per body at gzip -6 — three orders of magnitude below the network time it saves. Even on a fast 20 Mbps uplink, halving a 600 KB body saves ~120 ms per request; on slow links it's the difference between usable and not.
- Anthropic side benefit: proportional ingress bandwidth reduction across every long session.
Related
- #13911 (closed, not planned) — asked to upgrade request compression to brotli/zstd, assuming gzip might already be in use. This request differs on both counts: we verified bodies are currently uncompressed, and we ask only for gzip, which the server demonstrably accepts today (brotli/zstd are rejected with 400).
- #55411 (closed, not planned) — bandwidth-aware mode for metered connections; this is the single highest-leverage, lowest-cost slice of that.
- #14930 (closed, not planned) — token-level "compression" of context; unrelated mechanism (transport vs tokens).
- #85046 / #89262 (open) — the download side of the same theme: Claude Code's network layer assumes fast, reliable links.
Proposed Change
In the SDK/client fetch path (a small change — our whole proof-of-concept proxy is ~170 lines):
- gzip request bodies above a small threshold (~1 KB) with
Content-Encoding: gzip, by default. - Retry once uncompressed on an unexpected 4xx, to survive rare middleboxes that mishandle request
Content-Encoding. - Env-var kill switch (e.g.
CLAUDE_CODE_DISABLE_REQUEST_COMPRESSION=1).
Workaround for anyone hitting this today
A local ANTHROPIC_BASE_URL proxy that gzips bodies before forwarding to api.anthropic.com works out of the box and is what produced the numbers above.
<details>
<summary>
Python script.
</summary>
#!/usr/bin/env python3
"""Local proxy for Claude Code on slow connections.
Sits between Claude Code and api.anthropic.com and gzip-compresses every
request body (the full conversation context is re-uploaded on each API call,
and it compresses ~2x on real traffic). Streaming (SSE) responses are passed through
incrementally, decompressed locally if the upstream compressed them.
Upstream TLS connections are pooled per client-connection thread, so a
keep-alive client pays the handshake once, not per request.
Usage:
python3 claude-slim-proxy.py [port] # default 8377
ANTHROPIC_BASE_URL=http://127.0.0.1:8377 claude
Stdlib only. No credentials are read or stored; auth headers pass through
unchanged to api.anthropic.com only.
"""
import gzip
import http.client
import sys
import threading
import zlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
UPSTREAM = "api.anthropic.com"
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8377
MIN_COMPRESS = 1024 # don't bother compressing tiny bodies
HOP_HEADERS = {"connection", "keep-alive", "transfer-encoding", "te",
"proxy-authorization", "proxy-authenticate", "upgrade",
"trailer", "host", "content-length", "accept-encoding"}
stats = {"in_raw": 0, "in_sent": 0}
stats_lock = threading.Lock()
tls = threading.local() # per-thread upstream connection
def upstream_conn(fresh=False):
conn = getattr(tls, "conn", None)
if fresh and conn is not None:
try:
conn.close()
except OSError:
pass
conn = None
if conn is None:
conn = http.client.HTTPSConnection(UPSTREAM, timeout=900)
tls.conn = conn
return conn
def drop_upstream():
conn = getattr(tls, "conn", None)
if conn is not None:
try:
conn.close()
except OSError:
pass
tls.conn = None
class Proxy(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args):
sys.stderr.write("[proxy] %s\n" % (fmt % args))
def _handle(self):
length = int(self.headers.get("content-length") or 0)
body = self.rfile.read(length) if length else b""
headers = {k: v for k, v in self.headers.items()
if k.lower() not in HOP_HEADERS
and not k.lower().startswith("proxy-")}
headers["Host"] = UPSTREAM
# Ask upstream for gzip; we decompress incrementally ourselves so the
# client always receives identity encoding.
headers["Accept-Encoding"] = "gzip"
raw_len = len(body)
already = any(k.lower() == "content-encoding" for k in headers)
if body and raw_len >= MIN_COMPRESS and not already:
body = gzip.compress(body, 6)
headers["Content-Encoding"] = "gzip"
headers["Content-Length"] = str(len(body))
with stats_lock:
stats["in_raw"] += raw_len
stats["in_sent"] += len(body)
saved = stats["in_raw"] - stats["in_sent"]
if raw_len >= MIN_COMPRESS:
self.log_message("%s %s up %dB -> %dB (total saved %.1fMB)",
self.command, self.path, raw_len, len(body),
saved / 1e6)
# Pooled upstream connection; one retry on a stale keep-alive socket.
resp = None
for attempt in (0, 1):
conn = upstream_conn(fresh=bool(attempt))
try:
conn.request(self.command, self.path, body=body, headers=headers)
resp = conn.getresponse()
break
except (OSError, http.client.HTTPException) as e:
drop_upstream()
if attempt:
self.send_error(502, explain="upstream error: %s" % e)
return
gzipped = (resp.getheader("Content-Encoding") or "").lower() == "gzip"
# 16+MAX_WBITS = gzip container
inflater = zlib.decompressobj(16 + zlib.MAX_WBITS) if gzipped else None
upstream_len = resp.getheader("Content-Length")
# If we must transform (gunzip) or upstream is chunked, we can't trust
# Content-Length; stream with chunked framing instead.
use_chunked = gzipped or upstream_len is None
self.send_response(resp.status)
for k, v in resp.getheaders():
lk = k.lower()
if lk in HOP_HEADERS or lk in ("content-encoding", "content-length"):
continue
self.send_header(k, v)
if use_chunked:
self.send_header("Transfer-Encoding", "chunked")
else:
self.send_header("Content-Length", upstream_len)
self.end_headers()
completed = False
try:
while True:
chunk = resp.read1(65536)
if not chunk:
break
if inflater:
chunk = inflater.decompress(chunk)
if not chunk:
continue
if use_chunked:
self.wfile.write(b"%x\r\n%s\r\n" % (len(chunk), chunk))
else:
self.wfile.write(chunk)
self.wfile.flush()
if inflater:
tail = inflater.flush()
if tail and use_chunked:
self.wfile.write(b"%x\r\n%s\r\n" % (len(tail), tail))
if use_chunked:
self.wfile.write(b"0\r\n\r\n")
self.wfile.flush()
completed = True
except (BrokenPipeError, ConnectionResetError):
pass # client went away mid-stream
finally:
# Only keep the upstream socket if the response was fully drained;
# a half-read stream would poison the next pooled request.
if not completed:
drop_upstream()
do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = _handle
if __name__ == "__main__":
srv = ThreadingHTTPServer(("127.0.0.1", PORT), Proxy)
srv.daemon_threads = True
print("claude-slim-proxy listening on http://127.0.0.1:%d -> https://%s" % (PORT, UPSTREAM))
print("run: ANTHROPIC_BASE_URL=http://127.0.0.1:%d claude" % PORT)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
</details>
Priority
High - Significant impact on productivity
Feature Category
Performance and speed