●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Adding Priority and Fairness to a Claude API Job Queue — Backpressure Patterns from a 50M-Download Indie App
A practical design for adding three-tier priority queues and Deficit Round Robin fairness to a Claude API worker. Drawn from running review-automation pipelines across an indie app catalog with 50M cumulative downloads, with full Python code and production metrics from a year of operation.
About a year after I started shipping a Claude-API-backed feature in production, I noticed something specific: most of my outages had nothing to do with "slow Claude days." They were about the order in which requests stacked up. The trigger was a small incident in an App Store review auto-reply worker I run for the indie apps I built — one morning a flood of long English reviews arrived, and quietly behind them Japanese reviews sat untouched for ninety minutes.
When I counted the morning's traffic, I had three English reviews of 18,000+ characters each — about 9,000 input tokens — and each Claude API call took 12–18 seconds. The Japanese reviews were closer to 600 characters and should have returned in about 1.5 seconds. But they arrived after the three long ones in a FIFO queue, and four parallel workers were tied up on the long jobs for the full 22 seconds. A naive FIFO worker design was quietly sacrificing short-input users at peak.
This article is the design I evolved over the six months after that. The official Anthropic SDK tells you to "cap your parallelism" and "design for retries," but once you run a multi-tenant Claude pipeline in production for a year, that turns out to be table stakes. Today I'll share the three-tier priority queue I now run, plus the Deficit Round Robin scheduler that keeps tenants from starving each other — with the actual code and metrics.
Why "request arrival order" is the wrong default
My first design was painfully simple: enqueue jobs onto a Redis LIST, four workers BRPOP'ing in parallel. The Claude messages.create calls fit comfortably inside my tier's parallelism budget, and on average days nothing felt off.
But when input-token sizes get lopsided, FIFO falls apart. Concretely:
Long jobs (18,000 chars ≈ 9,000 input tokens): median latency 12.4 seconds, p99 21.8 seconds
Short jobs (600 chars ≈ 300 input tokens): median latency 1.5 seconds, p99 3.2 seconds
When all four workers grab long jobs, every short job that arrives after waits up to 22 seconds
If you compare "throughput on 100 short jobs" vs "throughput on 3 long jobs," the long jobs dominate. This is classic Head-of-Line Blocking — networking and database folks have had answers for it for decades — and it shows up identically in a Claude worker pool.
The way I first noticed it was that a user in my Discord asked me why my reply was so slow. I had told myself review replies were "async, no SLA," but in practice users get anxious after just a few minutes. FIFO looks fair on paper, but it systematically penalizes the majority of small-input users while the few large ones consume the worker pool.
Three design goals — the latency / throughput / fairness tradeoff
To fix this, I sat down and rewrote my goals in priority order. The order matters; the upper ones trump the lower.
Instant response on the critical path: any request triggered by a direct user action (e.g. an in-app "ask AI" button) should return within p99 ≤ 2 seconds
Fairness between tenants: if one tenant (in my case, one app ID across my portfolio) dumps in a flood of jobs, other tenants' average wait should not rise more than 1.5×
Maximize batch throughput: overnight bulk jobs should burn through the rails as hard as possible — but only when they don't violate (1) and (2)
These three pull against each other. Maximize (1) and (3) stalls. Maximize (2) and the tail of (1) grows (long jobs delay shorter ones). After a year of tuning, I ended up assigning each goal to a different mechanism:
(1) is handled by the three-tier priority queue with critical taking precedence
(2) is handled by DRR (Deficit Round Robin) topping up per-tenant quota on every round
(3) is handled by giving the batch tier a minimum guaranteed share plus delegating to the Anthropic message_batches API for very large jobs
What follows is the implementation. The code is Python asyncio, but the structure transfers cleanly to Node.js or Go.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Working Python code (~180 lines) for a three-tier priority queue (critical/standard/batch) combined with Deficit Round Robin fairness scheduling
✦Measured p99 latency reduction from 11.2s to 1.8s and the starvation-avoidance design decisions behind those numbers
✦Auto-demotion of budget-exceeding tenants while respecting 429 / Retry-After, plus four production gotchas surfaced after a year of running this in front of real users
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Implementing the three-tier priority queue — critical / standard / batch
My worker keeps three logical queues in Redis, each a separate LIST, distinguished only by priority label. The enqueuing side decides which queue a request belongs in.
# enqueue.py — classification on the producer sidedef classify_priority(req) -> str: # 1. user-action-triggered -> critical if req.origin == "user_action": return "critical" # 2. anything Claude can answer within 24h -> batch if req.sla_seconds is None or req.sla_seconds >= 86400: return "batch" # 3. everything else (review replies, periodic summaries) -> standard return "standard"def enqueue(redis, req): priority = classify_priority(req) payload = json.dumps(req.to_dict()) redis.lpush(f"queue:{priority}:{req.tenant_id}", payload) redis.zincrby("tenant:enqueued_count", 1, req.tenant_id)
The worker, in its main loop, picks which queue to read from. The naive rule is "critical if any, else standard, else batch," but that starves batch whenever standard keeps refilling. To prevent starvation I added a minimum guarantee.
# worker.py — picking a priority on each loop iterationimport random# weights: critical 70%, standard 25%, batch 5%PRIORITY_WEIGHTS = [("critical", 70), ("standard", 25), ("batch", 5)]def pick_priority(redis) -> str | None: # critical is absolutely preferred when non-empty if has_any(redis, "critical"): return "critical" # weighted random between standard and batch r = random.random() * 100 cumulative = 0 for name, w in PRIORITY_WEIGHTS: cumulative += w if r < cumulative: if has_any(redis, name): return name return None
The detail that matters here: critical is hard-preferred when non-empty, but standard vs batch is probabilistic. If you write "always pick standard before batch," batch starves indefinitely under any steady standard inflow — I made that mistake and froze a nightly bulk job for hours. Probabilistic share guarantees batch at least 5% of throughput in the worst case.
DRR (Deficit Round Robin) for tenant-level fairness
Splitting traffic by priority doesn't solve fairness within a priority. Inside the critical queue, if tenant A has 100 enqueued jobs and tenant B has 1, naive FIFO puts B at position 101. DRR (Deficit Round Robin) is the classic fix — give each tenant a quota that refills every round, and let them dequeue up to their accumulated quota.
# drr.py — Deficit Round Robin schedulerclass DRRScheduler: def __init__(self, redis, quantum: int = 5): self.redis = redis self.quantum = quantum # quota refilled per round self.deficit: dict[str, int] = {} def pick(self, priority: str) -> tuple[str, bytes] | None: active = self._active_tenants(priority) if not active: return None for tenant_id in active: self.deficit.setdefault(tenant_id, 0) self.deficit[tenant_id] += self.quantum while self.deficit[tenant_id] >= 1: payload = self.redis.rpop(f"queue:{priority}:{tenant_id}") if payload is None: break # tenant out of work cost = self._estimate_cost(payload) if cost > self.deficit[tenant_id]: # not enough quota; carry over to next round self.redis.rpush(f"queue:{priority}:{tenant_id}", payload) break self.deficit[tenant_id] -= cost return tenant_id, payload return None def _estimate_cost(self, payload: bytes) -> int: # cheap proxy: input_chars / 4000 ≈ estimated token cost req = json.loads(payload) return max(1, len(req["input"]) // 4000)
With this scheduler, tenant A's queue head stops dequeuing once their quota runs out, and tenant B picks up on the next round. Measured: in a scenario where tenant A dumped 100 jobs into critical, tenant B's single job went from p99 9.8s → 1.7s.
The quantum value needs production tuning. I started with quantum=1 and saw long-input jobs get stuck (deficit never grew enough to dequeue them). I settled on quantum=5 (about 20,000 input tokens per round) after watching real distributions.
Backpressure and 429 hygiene — respect Retry-After
Once priority and fairness are in place, you still have to deal with Anthropic-side rate limits. On my tier I'm capped at 50 requests/min, 40,000 input tokens/min, and 8,000 output tokens/min — exceed any of those and you get 429.
Naive immediate retries make 429 storms worse. What stabilized production for me was always reading Retry-After and sleeping 1.2× that value with ±20% jitter.
record_throttle is the part that matters. I log per-tenant 429 counts in a Redis sorted set keyed to a 60-second sliding window, and use it as one of the inputs to the auto-demotion logic in the next section. Attributing 429s to a specific tenant (rather than "the worker") breaks the cycle where one runaway tenant punishes everyone else.
Measured: switching from naive exponential backoff to jittered backoff cut my 429 re-occurrence rate from 38% to 7%. Jitter isn't cosmetic — without it, multiple workers retry on the same millisecond and trigger thundering herds against Anthropic.
Auto-demoting tenants who exceed budget
DRR keeps fairness in steady state, but if one tenant sustains over-budget input forever, they still erode standard's overall throughput. My rule: any tenant that triggers 5+ 429s in the last 60 seconds, OR exceeds 150% of their daily input-token budget, gets auto-demoted from standard to batch for five minutes.
# tenant_demotion.pyDEMOTION_THRESHOLDS = { "throttle_60s": 5, "daily_budget_ratio": 1.5,}def check_and_demote(redis, tenant_id: str) -> bool: now = time.time() redis.zremrangebyscore(f"throttle:{tenant_id}", 0, now - 60) throttle_count = redis.zcard(f"throttle:{tenant_id}") daily_tokens = int(redis.get(f"tokens:{tenant_id}:{today_key()}") or 0) budget = int(redis.get(f"budget:{tenant_id}") or 100000) if (throttle_count >= DEMOTION_THRESHOLDS["throttle_60s"] or daily_tokens > budget * DEMOTION_THRESHOLDS["daily_budget_ratio"]): redis.setex(f"demoted:{tenant_id}", 300, "1") # 5-min batch demotion return True return False
When a tenant is in demoted state, classify_priority on the enqueue side rewrites their priority to batch regardless of what they requested. Demotion auto-expires after five minutes. It's less "punishment" and more "congestion control" — system self-defense, not tenant accountability.
A real-world example from my apps: a third-party review-aggregation service I had integrated started hammering the API far above their contract, and they (as a tenant) were starving review replies for my other apps. After auto-demotion shipped, any runaway tenant's impact on neighbors converges within five minutes. AdMob eCPM impact during such events stays inside about a 12% throughput dip rather than going to zero — comfortably inside what my revenue can absorb.
Operational metrics and SLOs — measure the tail
Once the scheduler and backpressure are running, you need to confirm they're actually working. The six metrics I now export to Prometheus / Grafana:
claude_queue_depth{priority} — backlog size per queue (gauge, every 30s)
claude_demoted_tenants — current count of demoted tenants
claude_drr_fairness_index — Jain's fairness index over the DRR scheduler
claude_input_tokens_total{tenant_id} — cumulative input tokens per tenant
The two that matter most are p99 latency and Jain's fairness index. p50 always looks great — it's the tail where the actual user pain hides. My SLOs are: critical p99 ≤ 2.0s, standard p99 ≤ 6.0s, batch p99 ≤ 120s.
Jain's fairness index is computed as (Σx_i)² / (n × Σx_i²). It's 1.0 when every tenant gets identical service and approaches 1/n when one tenant dominates. Before my scheduler shipped, the index hovered at 0.42 with 8 tenants (one tenant was eating almost everything). After DRR it sits between 0.86–0.91. The metric is comparable across tenant-count changes, which makes it nicer for ongoing health checks than raw per-tenant share.
There's a risk in being "too fair," though. If every tenant gets exactly equal share, paying customers stop hitting the latency they're paying for, and the pricing tiers lose their meaning. I give paid tenants (Pro plan and up) a 3× DRR quantum, which intentionally keeps the fairness index closer to 0.85 than to 1.00.
Four gotchas surfaced over a year in production
That's the design. To close, four things I only learned by running this in front of real users:
1. Absolute priority on critical causes priority inversion
Hard-preferring critical makes workers sit idle waiting for the next critical job when one is "about to arrive," and total standard throughput drops. I switched from "critical always wins" to "critical wins with probability 0.9" per worker. That alone dropped standard p50 from 4.1s to 2.6s.
2. Synchronized quantum refills cause API microbursts
A naive DRR implementation refills every tenant's quantum at the same instant, and every tenant simultaneously dequeues a job — producing a microburst against Claude. I jitter the refill timestamp per tenant (hash-based, 0–1000ms spread). The standard deviation of per-second requests to the Anthropic endpoint dropped from 3.4 to 1.1.
3. Never trust the caller to label their own priority
The first design let callers pass priority="critical". As the codebase grew, "I'll just send it as critical" became the default style. Now callers only specify origin (user_action / scheduled / batch_job), and the scheduler classifies priority centrally. Put that responsibility in one place.
4. Model upgrades shift queue parameters
When Claude Haiku 4.5 shipped, long-job processing time dropped by 54% (median). Suddenly work I was demoting to batch could clear standard just fine, and my demotion thresholds were over-aggressive. Model changes shift the optimal queue parameters. I re-tune quantum and demotion thresholds once a quarter against a 30-day latency distribution.
A year in, the same Claude tier and the same worker count feel like a completely different product after the priority queue and DRR landed. The most concrete change isn't a graph — it's that I get paged from 3×/week to 1×/month. Design isn't only about user experience; it also affects how much its operator sleeps.
If you want to start, copy the code above and run with quantum=5 and a 5% batch guarantee for a week. Watch p99 and Jain's fairness index, and you'll quickly develop intuition for the right quantum against your own workload. If this saves you the same week-long detour I went through, that's exactly the audience I had in mind when writing it.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.