<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: jidonglab</title>
    <description>The latest articles on DEV Community by jidonglab (@ji_ai).</description>
    <link>https://dev.to/ji_ai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3791767%2F6eb19afc-a99c-4736-9d12-459108893a16.png</url>
      <title>DEV Community: jidonglab</title>
      <link>https://dev.to/ji_ai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ji_ai"/>
    <language>en</language>
    <item>
      <title>tiktoken vs count_tokens: My Claude Budget Was 17% Off</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Wed, 16 Sep 2026 16:59:57 +0000</pubDate>
      <link>https://dev.to/ji_ai/tiktoken-vs-counttokens-my-claude-budget-was-17-off-1mdl</link>
      <guid>https://dev.to/ji_ai/tiktoken-vs-counttokens-my-claude-budget-was-17-off-1mdl</guid>
      <description>&lt;p&gt;My budget guard said the prompt was 171,000 tokens. Haiku 4.5's context window is 200,000. Plenty of room. The API returned a 400 anyway: prompt too long.&lt;/p&gt;

&lt;p&gt;That was run 1,102 of a pipeline I'd been babysitting for three weeks, and it was the first time I seriously questioned the little function at the top of my code that counted tokens with &lt;code&gt;tiktoken&lt;/code&gt;. So I logged both numbers — my local estimate and the &lt;code&gt;usage&lt;/code&gt; the API actually reported — for every request for the rest of the month. This is the tiktoken vs count_tokens comparison across 4,200 calls, and how far off the cheap local estimate really was.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tiktoken&lt;/code&gt; is OpenAI's tokenizer. It does not know Claude's vocabulary, so every number it gives you for a Claude prompt is a guess.&lt;/li&gt;
&lt;li&gt;Across 4,200 real requests my &lt;code&gt;tiktoken&lt;/code&gt; estimate ran a median &lt;strong&gt;17.4% below&lt;/strong&gt; the input tokens the API billed. Worst bucket (JSON tool results) was 38% low.&lt;/li&gt;
&lt;li&gt;Most of my error wasn't even the tokenizer. I tokenized &lt;code&gt;messages&lt;/code&gt; and forgot that &lt;strong&gt;tool definitions and the system prompt are part of every request&lt;/strong&gt; — 1,318 + 900 tokens I counted as zero.&lt;/li&gt;
&lt;li&gt;The fix is &lt;code&gt;POST /v1/messages/count_tokens&lt;/code&gt; with the same body you're about to send. It matched &lt;code&gt;usage&lt;/code&gt; exactly on every call I checked. Cost: one extra round trip, median 240ms.&lt;/li&gt;
&lt;li&gt;Do not compare your estimate against &lt;code&gt;usage.input_tokens&lt;/code&gt; alone. With prompt caching on, the bulk of your input shows up in &lt;code&gt;cache_read_input_tokens&lt;/code&gt; and your comparison will look insane.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What was I actually running?
&lt;/h2&gt;

&lt;p&gt;A cron pipeline that triages GitHub issues across my repos. Haiku 4.5 does the cheap first pass (read the issue, the linked file, the last 20 commits touching it, classify and summarize), Opus 5 drafts a patch for anything classified as a real bug. About 200 calls a day, 9 tools, a system prompt with a generated repo map glued to the end.&lt;/p&gt;

&lt;p&gt;The whole thing had a guard in front of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;
&lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_encoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cl100k_base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;LIMIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drop_oldest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks responsible. It's fiction with a dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does tiktoken undercount Claude tokens?
&lt;/h2&gt;

&lt;p&gt;Because it's a different tokenizer with a different vocabulary. &lt;code&gt;cl100k_base&lt;/code&gt; is OpenAI's BPE merge table. Claude has its own, and the merges don't line up, so the same string splits into a different number of pieces. There is no conversion ratio you can multiply by — the drift depends entirely on what's in the text.&lt;/p&gt;

&lt;p&gt;That last part is the trap. If the error were a flat 15%, you'd add a fudge factor and go home. It isn't flat. On my traffic the gap tracked content type hard:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content bucket&lt;/th&gt;
&lt;th&gt;Median undercount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain English issue bodies&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python and TS diffs&lt;/td&gt;
&lt;td&gt;24%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serialized JSON tool results&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stack traces and log dumps&lt;/td&gt;
&lt;td&gt;29%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a fudge factor tuned on prose blows up the moment the agent pastes a 400-line diff, which is exactly when the prompt is big enough to matter. My run 1,102 failure was a request that was 94% code by volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  How wrong was tiktoken vs count_tokens across 4,200 calls?
&lt;/h2&gt;

&lt;p&gt;Median 17.4% low, p95 34% low, best case 6% low. It was never once high. An estimator that only errs in the dangerous direction is worse than no estimator, because the guard is the thing that made me stop thinking about it.&lt;/p&gt;

&lt;p&gt;Concrete damage over 21 days:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;19 runs died&lt;/strong&gt; on context-limit 400s that my guard had waved through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input spend ran 21% above&lt;/strong&gt; what the estimator's forecast implied for the month. Tokens are the unit you're billed in, so an undercount is a bill surprise with extra steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My truncation logic fired late.&lt;/strong&gt; &lt;code&gt;drop_oldest&lt;/code&gt; triggered off the same wrong number, so the pipeline kept whole issue threads it should have dropped and dropped them only after a failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The counting bug and the retry bill compound: every 400 was followed by a truncate-and-retry, so the worst prompts got paid for twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does count_tokens count that your estimator doesn't?
&lt;/h2&gt;

&lt;p&gt;Everything the model actually reads. This was the bigger half of my error and it has nothing to do with tokenizers.&lt;/p&gt;

&lt;p&gt;A request isn't &lt;code&gt;messages&lt;/code&gt;. The API renders &lt;code&gt;tools&lt;/code&gt;, then &lt;code&gt;system&lt;/code&gt;, then &lt;code&gt;messages&lt;/code&gt;. My nine tool schemas came to 1,318 tokens, on every single call, sitting in a variable my estimator never saw. The system prompt with its generated repo map added another ~900. That's 2,218 tokens of pure blind spot before the first message.&lt;/p&gt;

&lt;p&gt;The endpoint takes the same body shape as &lt;code&gt;messages.create&lt;/code&gt;, which is the entire point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Anthropic&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-haiku-4-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# counts are model-specific
&lt;/span&gt;    &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SYSTEM_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TOOLS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# exact, for this model, for this body
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth internalizing. Counts are &lt;strong&gt;model-specific&lt;/strong&gt; — pass the model you're actually going to call, not whichever ID you had in a constant. And if you build the body in one place and count it in another, you will eventually count a body you don't send. That was my only remaining mismatch after the switch, and it was my bug, not the API's.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did my first comparison look completely broken?
&lt;/h2&gt;

&lt;p&gt;Because of prompt caching, and this cost me an evening. My first pass compared &lt;code&gt;estimate()&lt;/code&gt; against &lt;code&gt;response.usage.input_tokens&lt;/code&gt; and got results like estimate 41,000 versus actual 812. I assumed my logging was wrong.&lt;/p&gt;

&lt;p&gt;It wasn't. With caching on, &lt;code&gt;input_tokens&lt;/code&gt; is only the &lt;em&gt;uncached&lt;/em&gt; portion. The rest is reported separately in &lt;code&gt;cache_read_input_tokens&lt;/code&gt; and &lt;code&gt;cache_creation_input_tokens&lt;/code&gt;. If you want the number to compare a prompt-size estimate against, you have to add them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;
&lt;span class="n"&gt;billed_input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_read_input_tokens&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_creation_input_tokens&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful side effect: once you're summing those three, a &lt;code&gt;cache_read_input_tokens&lt;/code&gt; of zero across repeated requests is a loud signal that something in your prefix is changing between calls and your cache is silently never hitting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does one extra count_tokens call per request slow you down?
&lt;/h2&gt;

&lt;p&gt;On my pipeline, no. Median 240ms, p95 610ms, against calls that already take multiple seconds. 200 counts a day is noise.&lt;/p&gt;

&lt;p&gt;In a tight per-user loop it's a different story, so two things I'd actually do:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Count the static prefix once.&lt;/strong&gt; Tools plus system don't change between runs. Count them at startup, cache the number, and only count the volatile messages per request. One caveat I'd rather you hear from me than discover: token counts are not perfectly additive. Counting the whole body and summing the parts differed by 0 to 4 tokens on my requests. Tiny, real, and enough that I pad the budget by 1% instead of pretending the arithmetic is exact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't count in a fan-out.&lt;/strong&gt; I briefly counted every candidate chunk in a retrieval step and caught a 429 — the endpoint has its own limits and it is not free of rate limiting just because it's cheap. Count the assembled prompt, once.&lt;/p&gt;

&lt;p&gt;The honest limitation: &lt;code&gt;count_tokens&lt;/code&gt; tells you the input exactly and the output not at all. Output is unknowable until it's generated. I still reserve headroom equal to &lt;code&gt;max_tokens&lt;/code&gt; on top of the counted input, which is the only part of my original guard that survived.&lt;/p&gt;

&lt;h2&gt;
  
  
  So should you use tiktoken to count Claude tokens?
&lt;/h2&gt;

&lt;p&gt;No. &lt;code&gt;tiktoken&lt;/code&gt; is OpenAI's tokenizer, and on 4,200 real Claude calls it undercounted my prompts by a median of 17.4% — never once erring high, and drifting worst (38%) on the JSON and code payloads that make prompts big in the first place. Half my error came from the wrong tokenizer and half from counting only &lt;code&gt;messages&lt;/code&gt; while tool schemas and the system prompt quietly added 2,218 tokens to every request. Use &lt;code&gt;client.messages.count_tokens()&lt;/code&gt; with the same &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;system&lt;/code&gt;, &lt;code&gt;tools&lt;/code&gt;, and &lt;code&gt;messages&lt;/code&gt; you're about to send; it returns the exact input count for one extra round trip of a few hundred milliseconds. And when you compare it against reality, sum &lt;code&gt;input_tokens&lt;/code&gt; with the two &lt;code&gt;cache_*&lt;/code&gt; fields, or prompt caching will make your own logs look like they're lying to you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>python</category>
      <category>performance</category>
    </item>
    <item>
      <title>Speculative Decoding Acceptance Rate: My LLM Got 47% Slower</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Wed, 16 Sep 2026 04:58:42 +0000</pubDate>
      <link>https://dev.to/ji_ai/speculative-decoding-acceptance-rate-my-llm-got-47-slower-21if</link>
      <guid>https://dev.to/ji_ai/speculative-decoding-acceptance-rate-my-llm-got-47-slower-21if</guid>
      <description>&lt;p&gt;I turned on speculative decoding on a Friday afternoon expecting a free 2x. My local 32B model went from 34 tokens/sec to 18.&lt;/p&gt;

&lt;p&gt;Not noise. Not a warmup artifact. Consistently, reproducibly, half speed. I had added a second model to my GPU, burned VRAM for it, and made everything worse.&lt;/p&gt;

&lt;p&gt;The thing nobody tells you: speculative decoding is not a speedup. It's a &lt;strong&gt;bet&lt;/strong&gt;, and the speculative decoding acceptance rate is the odds. Get the odds wrong and you pay for every draft token you throw away.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Speculative decoding runs a small draft model for &lt;code&gt;k&lt;/code&gt; tokens, then verifies all &lt;code&gt;k&lt;/code&gt; in &lt;strong&gt;one&lt;/strong&gt; forward pass of the big model. Output distribution is mathematically identical to normal decoding.&lt;/li&gt;
&lt;li&gt;It wins only when the &lt;strong&gt;acceptance rate&lt;/strong&gt; (fraction of drafted tokens the target model keeps) is high enough to pay for the draft model's cost.&lt;/li&gt;
&lt;li&gt;Speedup is &lt;code&gt;E(k) / (1 + k·c)&lt;/code&gt; where &lt;code&gt;E(k) = 1 + α + α² + ... + αᵏ&lt;/code&gt;, α is acceptance rate, and &lt;code&gt;c&lt;/code&gt; is draft-step cost divided by target-step cost.&lt;/li&gt;
&lt;li&gt;My α was 0.71 on code and 0.32 on English prose. At draft length 16, prose ran at &lt;strong&gt;0.50x&lt;/strong&gt;. Dropping to draft length 4 gave 1.91x on code and break-even on prose.&lt;/li&gt;
&lt;li&gt;Fix in order: measure α per workload, shrink &lt;code&gt;k&lt;/code&gt;, then pick a better-matched draft model. Long drafts amplify a bad α instead of rescuing it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is speculative decoding actually doing?
&lt;/h2&gt;

&lt;p&gt;Speculative decoding exploits one fact about LLM inference: &lt;strong&gt;decoding a single token is memory-bandwidth bound, not compute bound.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To produce one token, your GPU streams every weight in the model from VRAM through the compute units. A 32B model at Q4 means moving ~19 GB per token. The matrix multiplies themselves barely make the GPU sweat. The tensor cores are mostly idle, waiting on memory.&lt;/p&gt;

&lt;p&gt;So here's the trick: if you feed the target model 5 candidate tokens instead of 1, it still reads those 19 GB exactly once. Verifying 5 positions costs roughly what verifying 1 position costs. You get four extra tokens of work for free, as long as you had candidates to check.&lt;/p&gt;

&lt;p&gt;That's where the draft model comes in. A tiny model (0.5B, same tokenizer family) runs &lt;code&gt;k&lt;/code&gt; cheap decode steps and guesses what comes next. The target model then verifies all &lt;code&gt;k&lt;/code&gt; guesses in a single pass and accepts the longest prefix that matches what it would have sampled itself.&lt;/p&gt;

&lt;p&gt;The verification step uses rejection sampling, so the output distribution is &lt;strong&gt;identical&lt;/strong&gt; to running the target model alone. This isn't an approximation, and you aren't trading quality for speed. (You won't get token-for-token identical output to a non-speculative run with the same seed, because the RNG gets consumed differently, but the distribution is the same.)&lt;/p&gt;

&lt;p&gt;That's the whole mechanism. The entire question is how many of those guesses survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did speculative decoding make my model slower?
&lt;/h2&gt;

&lt;p&gt;Because every rejected draft token is pure waste, and I was drafting 16 of them at a time.&lt;/p&gt;

&lt;p&gt;Here's the arithmetic. Let α be the per-token acceptance rate and &lt;code&gt;k&lt;/code&gt; the draft length. The expected number of tokens you commit per target forward pass is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;E(k) = 1 + α + α² + ... + αᵏ
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;1 +&lt;/code&gt; is the free bonus token: even if the draft model's very first guess is wrong, the target's own verification pass produces a correct token, so you never come out with zero.&lt;/p&gt;

&lt;p&gt;The cost of that iteration is one target step plus &lt;code&gt;k&lt;/code&gt; draft steps. Call &lt;code&gt;c&lt;/code&gt; the ratio of draft-step time to target-step time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;speedup = E(k) / (1 + k·c)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My setup: Qwen2.5-Coder-32B-Instruct at Q4_K_M as the target, Qwen2.5-Coder-0.5B as the draft, single 24 GB card. Measured &lt;code&gt;c ≈ 0.12&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;c&lt;/code&gt; surprised me. The draft model has 64x fewer parameters, so I expected it to be 64x cheaper. It isn't. A 0.5B model's decode step is dominated by kernel launches, sampling, and per-step Python overhead, not by memory traffic. &lt;strong&gt;Small models don't get cheap in proportion to their size.&lt;/strong&gt; Eight draft tokens cost me almost as much as a full target step.&lt;/p&gt;

&lt;p&gt;Now plug in my two workloads:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Draft length &lt;code&gt;k&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Prose, α=0.32&lt;/th&gt;
&lt;th&gt;Code, α=0.71&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1.18x&lt;/td&gt;
&lt;td&gt;1.53x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1.15x&lt;/td&gt;
&lt;td&gt;1.79x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0.99x&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.91x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0.75x&lt;/td&gt;
&lt;td&gt;1.68x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.50x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.18x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At &lt;code&gt;k=16&lt;/code&gt; on prose the formula predicts 0.50x. I measured 18 tok/s against a 34 tok/s baseline. That's 0.53x. The model wasn't broken. I was spending 16 draft steps to buy, on average, 1.47 tokens.&lt;/p&gt;

&lt;p&gt;Notice the code column too. At α=0.71, going from &lt;code&gt;k=4&lt;/code&gt; to &lt;code&gt;k=16&lt;/code&gt; &lt;em&gt;also&lt;/em&gt; loses speed, 1.91x down to 1.18x. Long drafts have diminishing returns on the gain side (α^k collapses toward zero) and perfectly linear growth on the cost side. That asymmetry is the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I measure my speculative decoding acceptance rate?
&lt;/h2&gt;

&lt;p&gt;Don't guess it. Every serving stack reports it, you just have to go look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;llama.cpp&lt;/strong&gt;: run &lt;code&gt;llama-speculative&lt;/code&gt; or the server with &lt;code&gt;-md draft.gguf&lt;/code&gt;, and the end-of-run stats include &lt;code&gt;n_drafted&lt;/code&gt; and &lt;code&gt;n_accept&lt;/code&gt;. Your α is &lt;code&gt;n_accept / n_drafted&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; qwen2.5-coder-32b-q4_k_m.gguf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-md&lt;/span&gt; qwen2.5-coder-0.5b-q8_0.gguf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--draft-max&lt;/span&gt; 4 &lt;span class="nt"&gt;--draft-min&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;vLLM&lt;/strong&gt;: scrape the Prometheus endpoint for &lt;code&gt;vllm:spec_decode_num_accepted_tokens_total&lt;/code&gt; and &lt;code&gt;vllm:spec_decode_num_draft_tokens_total&lt;/code&gt;. Divide one by the other. Config shape depends on your version, recent builds take a JSON blob:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--speculative-config&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "Qwen/Qwen2.5-Coder-0.5B",
                       "num_speculative_tokens": 4}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then sweep &lt;code&gt;k&lt;/code&gt; offline before you touch the server. Six lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;speedup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;speedup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
             &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;speedup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.71&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measure α on a real sample of your traffic, measure &lt;code&gt;c&lt;/code&gt; with two quick benchmarks, then read the optimal &lt;code&gt;k&lt;/code&gt; off the table. This took me ten minutes and I should have done it before I started.&lt;/p&gt;

&lt;h2&gt;
  
  
  What moves the acceptance rate?
&lt;/h2&gt;

&lt;p&gt;α is not a property of your models. It's a property of your &lt;strong&gt;workload&lt;/strong&gt;, and it swings hard.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Predictable text wins.&lt;/strong&gt; Code, JSON, boilerplate, and anything that echoes the prompt draft well. My highest α was a structured-output endpoint at 0.83. Brace, quote, key name, closing brace: a 0.5B model nails that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open-ended prose loses.&lt;/strong&gt; Summaries and chat replies sat at 0.32. The draft model and the target genuinely disagree about what the next adjective should be, and there's no fixing that with config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temperature matters.&lt;/strong&gt; On the same code prompts, going from T=0 to T=0.8 dropped my α from 0.71 to 0.58. Higher temperature means the target samples further from the draft's mode more often.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tokenizer mismatch is fatal.&lt;/strong&gt; Draft and target must share a vocabulary. A different tokenizer isn't "lower acceptance," it's broken output or a hard error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch size can invert the whole thing.&lt;/strong&gt; At batch size 1 verification is nearly free because the GPU was idle anyway. At high concurrency your target step is already compute-saturated, so verifying &lt;code&gt;k&lt;/code&gt; extra positions across &lt;code&gt;B&lt;/code&gt; sequences costs real FLOPs. Speculative decoding can reduce aggregate throughput on a busy server even with a great α. Benchmark at your actual concurrency, not at batch 1.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Route by workload instead of flipping one global flag.&lt;/p&gt;

&lt;p&gt;My code-completion path runs with &lt;code&gt;--draft-max 4&lt;/code&gt; and gets a real 1.9x. My summarization path runs with speculative decoding &lt;strong&gt;off&lt;/strong&gt;, because the best possible config there was 1.18x at &lt;code&gt;k=1&lt;/code&gt; and that isn't worth a second model in VRAM.&lt;/p&gt;

&lt;p&gt;For the one endpoint that rewrites files I switched to n-gram speculation (&lt;code&gt;"method": "ngram"&lt;/code&gt; in vLLM) instead of a draft model. When the output is mostly copied from the input, matching prompt n-grams is a near-perfect draft with &lt;code&gt;c&lt;/code&gt; close to zero. No second model, no extra VRAM.&lt;/p&gt;

&lt;p&gt;And if you're serious about a draft model, a trained speculator head like EAGLE or Medusa is a different class of tool. Those are trained against your specific target's hidden states, which is exactly the α problem attacked at the root instead of by tuning &lt;code&gt;k&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why does speculative decoding make some models slower?
&lt;/h2&gt;

&lt;p&gt;Speculative decoding makes your LLM slower whenever the acceptance rate is too low to pay for the draft model's compute. The speedup is &lt;code&gt;E(k)/(1 + k·c)&lt;/code&gt;, where the gain from a longer draft decays geometrically as &lt;code&gt;α^k&lt;/code&gt; while the cost grows linearly as &lt;code&gt;k·c&lt;/code&gt;. On unpredictable text with α around 0.3, a draft length of 16 spends 16 cheap forward passes to buy roughly 1.5 tokens and lands near half your original throughput. Measure α per workload from your server's accepted-vs-drafted counters, sweep &lt;code&gt;k&lt;/code&gt; with the formula before deploying, and keep in mind that the right &lt;code&gt;k&lt;/code&gt; for JSON generation is not the right &lt;code&gt;k&lt;/code&gt; for chat.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>performance</category>
      <category>machinelearning</category>
      <category>ai</category>
    </item>
    <item>
      <title>Docker Compose depends_on Doesn't Wait. Neither Does pg_isready</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Tue, 15 Sep 2026 16:57:28 +0000</pubDate>
      <link>https://dev.to/ji_ai/docker-compose-dependson-doesnt-wait-neither-does-pgisready-179b</link>
      <guid>https://dev.to/ji_ai/docker-compose-dependson-doesnt-wait-neither-does-pgisready-179b</guid>
      <description>&lt;p&gt;Nine of my last 47 CI runs died on the same line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: connect ECONNREFUSED 172.19.0.3:5432
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same commit. Same image. Same compose file. On my laptop, green every single time. In CI, a coin flip with a bad attitude.&lt;/p&gt;

&lt;p&gt;The cause was four characters of YAML I had copy-pasted for years without ever reading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker Compose &lt;code&gt;depends_on&lt;/code&gt; doesn't wait for your database. It waits for a &lt;em&gt;container&lt;/em&gt;. Those are two completely different events, and the gap between them is where your flaky integration tests live.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;depends_on&lt;/code&gt; in short list syntax only waits for the dependency container to &lt;strong&gt;start&lt;/strong&gt; (&lt;code&gt;condition: service_started&lt;/code&gt;). It knows nothing about the process inside.&lt;/li&gt;
&lt;li&gt;To actually wait, use the long syntax with &lt;code&gt;condition: service_healthy&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; define a &lt;code&gt;healthcheck&lt;/code&gt; on the dependency. No healthcheck means no waiting, silently.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg_isready&lt;/code&gt; with no &lt;code&gt;-h&lt;/code&gt; talks to the &lt;strong&gt;Unix socket&lt;/strong&gt;. On a fresh volume the official Postgres image runs a temporary server on that socket while init scripts execute, so your healthcheck turns green before TCP 5432 is open. Use &lt;code&gt;pg_isready -h 127.0.0.1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;That's why it only breaks in CI: CI starts with an empty volume, your laptop has a warm one, so your laptop never sees the init phase.&lt;/li&gt;
&lt;li&gt;Healthchecks fix startup ordering. They don't fix reconnects. Keep retry logic in the app anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does depends_on actually wait for in Docker Compose?
&lt;/h2&gt;

&lt;p&gt;It waits for the container to reach the running state, and nothing more. Docker Compose &lt;code&gt;depends_on&lt;/code&gt; in its short form expands to &lt;code&gt;condition: service_started&lt;/code&gt;, which means "the container process has been launched." Postgres launching and Postgres accepting a connection on port 5432 are separated by anywhere from 200ms to about 15 seconds, depending on whether the data directory already exists.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;depends_on&lt;/code&gt; gives you three real guarantees, and it's worth knowing exactly what they are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start order. Dependencies come up first.&lt;/li&gt;
&lt;li&gt;Stop order. Dependents go down first.&lt;/li&gt;
&lt;li&gt;Nothing else.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It is not a readiness gate. It's a topological sort.&lt;/p&gt;

&lt;p&gt;You can watch the gap yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; db
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;db pg_isready &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1
&lt;span class="c"&gt;# pg_isready: no response  ← container is "up", DB is not&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container is running. &lt;code&gt;docker compose ps&lt;/code&gt; says &lt;code&gt;running&lt;/code&gt;. Your API container already started, already dialed 5432, already crashed, and if you set &lt;code&gt;restart: no&lt;/code&gt; it's already gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does adding a healthcheck still not fix it?
&lt;/h2&gt;

&lt;p&gt;Because the healthcheck can be subtly wrong in two ways, and Docker will report both of them as "healthy."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong tool.&lt;/strong&gt; This is the classic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8080/health"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most slim and alpine images don't ship &lt;code&gt;curl&lt;/code&gt;. The probe fails forever, the container sits in &lt;code&gt;starting&lt;/code&gt; then flips to &lt;code&gt;unhealthy&lt;/code&gt;, and your dependent service never boots at all. You get a hang instead of a crash, which is somehow worse to debug. Also note &lt;code&gt;CMD&lt;/code&gt; vs &lt;code&gt;CMD-SHELL&lt;/code&gt;: with &lt;code&gt;CMD&lt;/code&gt; the list is exec'd directly, so &lt;code&gt;||&lt;/code&gt;, &lt;code&gt;$VARS&lt;/code&gt;, and pipes do nothing. If you want shell semantics, you must say &lt;code&gt;CMD-SHELL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong endpoint.&lt;/strong&gt; This one is the actual villain in my CI logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks perfect. Ships with the image. Exits 0 when Postgres is accepting connections. It's also wrong, and here's the mechanic.&lt;/p&gt;

&lt;p&gt;When the official Postgres image boots with an &lt;strong&gt;empty&lt;/strong&gt; data directory, it does a two-phase startup. Phase one: &lt;code&gt;initdb&lt;/code&gt; creates the cluster, then the entrypoint starts a &lt;em&gt;temporary&lt;/em&gt; server to run everything in &lt;code&gt;/docker-entrypoint-initdb.d&lt;/code&gt;. That temporary server deliberately does not listen on TCP — the entrypoint starts it with empty &lt;code&gt;listen_addresses&lt;/code&gt;, so it's reachable only over the Unix socket inside the container. Phase two: the temp server is shut down and the real one starts, this time on 0.0.0.0:5432.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pg_isready&lt;/code&gt; with no &lt;code&gt;-h&lt;/code&gt; connects over the Unix socket. So during phase one it gets a happy answer, exits 0, Docker marks the container &lt;code&gt;healthy&lt;/code&gt;, Compose releases your API container, and your API dials TCP 5432 into a closed port.&lt;/p&gt;

&lt;p&gt;The fix is one flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-h&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-h 127.0.0.1&lt;/code&gt; forces a TCP connection to the container's own loopback, which is exactly the thing your dependent service is about to do. During phase one it returns non-zero, so the container stays &lt;code&gt;starting&lt;/code&gt;, so Compose keeps waiting. That's the whole bug.&lt;/p&gt;

&lt;p&gt;And this is why it only ever broke in CI. My laptop had a populated &lt;code&gt;pgdata&lt;/code&gt; volume from months ago, so the init phase never ran and phase one didn't exist. CI creates a fresh volume every run. The flake rate wasn't random — it tracked how long the seed scripts took that day.&lt;/p&gt;

&lt;p&gt;MySQL has the same two-phase shape, by the way. &lt;code&gt;mysqladmin ping&lt;/code&gt; can answer during the init window too. Same class of bug, same class of fix: probe the thing over the network path your app will actually use.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the Docker Compose config that actually works?
&lt;/h2&gt;

&lt;p&gt;Long syntax, real healthcheck, migrations as their own gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# -h 127.0.0.1 forces TCP. Without it this passes during&lt;/span&gt;
      &lt;span class="c1"&gt;# the init-scripts phase, when TCP 5432 is still closed.&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-h&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;

  &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;migrate"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;

  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
      &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_completed_successfully&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to read carefully there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;interval: 2s&lt;/code&gt; with &lt;code&gt;retries: 30&lt;/code&gt; is a 60-second patience budget. Default interval is 30s, which in a fresh-volume CI run means you either wait half a minute for a database that was ready in four seconds, or you blow the retry count. Short interval, generous retries.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;start_period: 5s&lt;/code&gt; is not a sleep. Probes still run during it — a success inside the start period marks the container healthy immediately. What it changes is that &lt;em&gt;failures&lt;/em&gt; during that window don't count against &lt;code&gt;retries&lt;/code&gt; and don't mark the container unhealthy. It's a grace period, not a delay.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;service_completed_successfully&lt;/code&gt; is the one condition people never discover. The &lt;code&gt;migrate&lt;/code&gt; service runs, exits 0, and only then does &lt;code&gt;api&lt;/code&gt; start. If migrations exit non-zero, &lt;code&gt;api&lt;/code&gt; never launches and &lt;code&gt;docker compose up&lt;/code&gt; fails loudly instead of starting an app against a half-migrated schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still breaks after you fix this?
&lt;/h2&gt;

&lt;p&gt;Healthy is not the same as correct, and a few sharp edges survive the fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Healthy ≠ migrated ≠ seeded.&lt;/strong&gt; &lt;code&gt;pg_isready&lt;/code&gt; says the server accepts connections. It says nothing about whether your tables exist. That's what the &lt;code&gt;migrate&lt;/code&gt; gate above is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Healthy ≠ healthy forever.&lt;/strong&gt; A database that passes at t=0 can OOM at t=90. &lt;code&gt;depends_on&lt;/code&gt; is a startup gate, fired once. Your app still needs connection retry with backoff. This is the part people skip after adding healthchecks, and it's the part that pages you at 3am.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swarm ignores it.&lt;/strong&gt; &lt;code&gt;docker stack deploy&lt;/code&gt; drops &lt;code&gt;depends_on&lt;/code&gt; entirely. If you're targeting Swarm, the ordering you wrote is decorative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI needs &lt;code&gt;--wait&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;docker compose up -d&lt;/code&gt; returns as soon as containers are created. Use &lt;code&gt;docker compose up -d --wait&lt;/code&gt; (with &lt;code&gt;--wait-timeout&lt;/code&gt;) so the command doesn't exit until everything is healthy, then run your tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check that the probe exists at all.&lt;/strong&gt; If you define no healthcheck, &lt;code&gt;docker inspect --format '{{json .State.Health}}' &amp;lt;container&amp;gt;&lt;/code&gt; prints &lt;code&gt;null&lt;/code&gt;, and &lt;code&gt;condition: service_healthy&lt;/code&gt; on that service will error out rather than quietly wait. Good — but it means a typo'd healthcheck key fails in a completely different way from a missing one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So does Docker Compose depends_on wait for the database?
&lt;/h2&gt;

&lt;p&gt;No. Docker Compose &lt;code&gt;depends_on&lt;/code&gt; in short list syntax only waits for the dependency's container to start, which for a database is several seconds before it accepts connections. To make it actually wait, define a &lt;code&gt;healthcheck&lt;/code&gt; on the dependency and switch to the long syntax with &lt;code&gt;condition: service_healthy&lt;/code&gt;. Then make sure the healthcheck probes the same network path your app uses: &lt;code&gt;pg_isready&lt;/code&gt; without &lt;code&gt;-h&lt;/code&gt; checks the Unix socket, which answers during the Postgres image's init-scripts phase while TCP 5432 is still closed, so use &lt;code&gt;pg_isready -h 127.0.0.1&lt;/code&gt; instead. Pair it with a migration service gated by &lt;code&gt;service_completed_successfully&lt;/code&gt;, keep &lt;code&gt;interval&lt;/code&gt; short and &lt;code&gt;retries&lt;/code&gt; high, and keep reconnect logic in your application, because startup ordering and runtime resilience are different problems.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>tooling</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Ollama keep_alive: My Model Reloaded 214 Times in One Day</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Tue, 15 Sep 2026 04:56:17 +0000</pubDate>
      <link>https://dev.to/ji_ai/ollama-keepalive-my-model-reloaded-214-times-in-one-day-il4</link>
      <guid>https://dev.to/ji_ai/ollama-keepalive-my-model-reloaded-214-times-in-one-day-il4</guid>
      <description>&lt;p&gt;My local chat app was fast every single time I tested it, and slow every single time I actually used it.&lt;/p&gt;

&lt;p&gt;That's the tell, and I ignored it for weeks. I'd type a question during development, get a first token in under a second, ship the change. Then I'd come back after lunch, ask one thing, and sit there for eleven seconds watching a cursor blink. Same box. Same model. Same prompt.&lt;/p&gt;

&lt;p&gt;It wasn't the model being slow. It was Ollama loading the model off disk again, because it had quietly evicted it while I was living my life. The knob is called &lt;code&gt;keep_alive&lt;/code&gt;, and Ollama &lt;code&gt;keep_alive&lt;/code&gt; turns out to have three separate ways of not doing what you think it does.&lt;/p&gt;

&lt;p&gt;So I put a timer on every request for 24 hours. 1,180 requests, 214 model load events. Here's the autopsy.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ollama unloads a model after &lt;strong&gt;5 minutes idle by default&lt;/strong&gt;. Any request arriving after that pays a full cold load from disk.&lt;/li&gt;
&lt;li&gt;On my box that cold load cost &lt;strong&gt;11.4s to first token vs 0.9s warm&lt;/strong&gt; — 18.1% of my requests were cold, which dragged my overall p50 to 3.1s.&lt;/li&gt;
&lt;li&gt;Passing &lt;code&gt;keep_alive&lt;/code&gt; in the request body did &lt;strong&gt;nothing&lt;/strong&gt; on the OpenAI-compatible &lt;code&gt;/v1/chat/completions&lt;/code&gt; endpoint. The native &lt;code&gt;/api/chat&lt;/code&gt; endpoint honors it.&lt;/li&gt;
&lt;li&gt;Setting &lt;code&gt;keep_alive: -1&lt;/code&gt; on two models that don't both fit in VRAM made things &lt;strong&gt;worse&lt;/strong&gt;, not better: partial CPU offload dropped generation from 42 tok/s to 6 tok/s.&lt;/li&gt;
&lt;li&gt;The fix was boring: &lt;code&gt;OLLAMA_KEEP_ALIVE=24h&lt;/code&gt; as a &lt;strong&gt;server-side env var&lt;/strong&gt;, one resident model per GPU, embeddings moved to a separate CPU-only Ollama instance. Loads went 214/day → 9/day.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does Ollama reload my model on every request?
&lt;/h2&gt;

&lt;p&gt;Because Ollama's default &lt;code&gt;keep_alive&lt;/code&gt; is 5 minutes. After five minutes with no traffic, the runner exits and the weights leave VRAM. The next request re-reads gigabytes from disk, re-allocates VRAM, and only then starts generating.&lt;/p&gt;

&lt;p&gt;This is a completely reasonable default for a laptop. It is a terrible default for anything with bursty traffic, which is every side project ever built.&lt;/p&gt;

&lt;p&gt;My setup, so you can judge whether my numbers transfer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One box, 16GB VRAM, models on an NVMe drive&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llama3.1:8b&lt;/code&gt; for chat (~4.9GB), &lt;code&gt;qwen2.5-coder:14b&lt;/code&gt; for a code helper (~9GB), &lt;code&gt;nomic-embed-text&lt;/code&gt; for embeddings (~274MB)&lt;/li&gt;
&lt;li&gt;Three clients: a chat UI I use by hand, a cron job that summarizes my notes every 10 minutes, and a small RAG indexer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Look at that cron interval. Every 10 minutes, against a 5-minute idle timeout. That job was cold &lt;strong&gt;100% of the time&lt;/strong&gt;. It had never once hit a warm model. For weeks I assumed "local summarization just takes 12 seconds."&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I check if Ollama is reloading my model?
&lt;/h2&gt;

&lt;p&gt;Two commands, thirty seconds, and you'll know.&lt;/p&gt;

&lt;p&gt;First, is anything resident right now?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that's empty while you think your model is "running," it's not. It's on disk. &lt;code&gt;UNTIL&lt;/code&gt; in that output is your real keep_alive, not what you put in your config.&lt;/p&gt;

&lt;p&gt;Second, count load events in the server log over a day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Linux (systemd)&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; ollama &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"24 hours ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ci&lt;/span&gt; &lt;span class="s2"&gt;"llama runner started"&lt;/span&gt;

&lt;span class="c"&gt;# macOS&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ci&lt;/span&gt; &lt;span class="s2"&gt;"llama runner started"&lt;/span&gt; ~/.ollama/logs/server.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log wording drifts between versions, so grep your own log once by hand and pick the line that appears exactly once per load. Mine said 214 over a day against 1,180 requests. That ratio is the whole story: one reload for every 5.5 requests.&lt;/p&gt;

&lt;p&gt;Then I wrapped the client to record time-to-first-token, because "it feels slow sometimes" is not a bug report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ttft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llama3.1:8b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/api/chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                      &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter_lines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;   &lt;span class="c1"&gt;# first real token
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log that number with a timestamp for a day. The histogram was not a bell curve. It was two spikes: one at 0.9s, one at 11.4s. Nothing in between. That shape means you have a binary state problem, not a slow model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does keep_alive work on the OpenAI-compatible endpoint?
&lt;/h2&gt;

&lt;p&gt;In my testing, no. This is the part that cost me an entire evening.&lt;/p&gt;

&lt;p&gt;My app talked to Ollama through the OpenAI SDK, pointed at &lt;code&gt;/v1&lt;/code&gt;, because that's the path of least resistance when you want to swap providers later. So I did the obvious thing and added &lt;code&gt;keep_alive&lt;/code&gt; to the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llama3.1:8b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[...],&lt;/span&gt;
    &lt;span class="n"&gt;extra_body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keep_alive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;24h&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;# did nothing on my version
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Load count the next day: 200-something. Unchanged. I'd "fixed" it and the graph didn't move, which is the only reason I caught it. If I hadn't been counting loads, I would have declared victory and kept eating 11-second requests.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;keep_alive&lt;/code&gt; is an Ollama concept, not an OpenAI one, and the compatibility layer on my version drops it. The native endpoint honors it fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:11434/api/chat &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
  "model": "llama3.1:8b",
  "messages": [{"role":"user","content":"hi"}],
  "keep_alive": "24h"
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't take my word for the version behavior. Send one request, then run &lt;code&gt;ollama ps&lt;/code&gt; and read the &lt;code&gt;UNTIL&lt;/code&gt; column. If it says 5 minutes from now, your &lt;code&gt;keep_alive&lt;/code&gt; was ignored, whatever the docs say about your build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did keep_alive: -1 make my local LLM slower?
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;-1&lt;/code&gt; means "never unload," and never-unload plus two models that don't both fit in 16GB means something has to give. What gives is layer placement.&lt;/p&gt;

&lt;p&gt;I set &lt;code&gt;keep_alive: -1&lt;/code&gt; on both the 8B chat model and the 14B coder model, feeling clever. Loads dropped from 214 to about 60 a day. Latency got worse.&lt;/p&gt;

&lt;p&gt;With both pinned, the 14B model no longer got a clean full-GPU allocation. It landed partially on the GPU with the remaining layers on CPU. Generation went from &lt;strong&gt;42 tok/s to 6 tok/s&lt;/strong&gt;. A 400-token answer went from 10 seconds to over a minute. I had traded a one-time 11-second cold start for a permanent 7x tax on every token.&lt;/p&gt;

&lt;p&gt;The lesson I'd tattoo on the inside of my eyelids: &lt;strong&gt;a cold start is cheaper than a partial offload.&lt;/strong&gt; Reloading is a fixed cost paid once. Spilling layers to CPU is a cost paid per token, forever, silently, and it never shows up in a load counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually fixed it?
&lt;/h2&gt;

&lt;p&gt;Four changes, in order of how much they mattered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Set keep_alive on the server, not in requests.&lt;/strong&gt; One env var covers every client, including the ones you forgot about and the ones going through &lt;code&gt;/v1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/ollama.service.d/override.conf&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;Service]
&lt;span class="nv"&gt;Environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"OLLAMA_KEEP_ALIVE=24h"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart ollama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On macOS, &lt;code&gt;launchctl setenv OLLAMA_KEEP_ALIVE 24h&lt;/code&gt; before starting the app. Setting it in your shell profile does nothing, because the server isn't your shell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. One resident model per GPU.&lt;/strong&gt; The chat model stays loaded. The coder model is invoked maybe 15 times a day, so I let it cold start and I stopped pretending that mattered. Alternating two big models on one GPU is thrash, and no timeout setting fixes thrash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Embeddings on a separate CPU-only instance.&lt;/strong&gt; The indexer was firing hundreds of tiny embedding calls and stealing VRAM for a 274MB model that runs fine on CPU:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CUDA_VISIBLE_DEVICES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="nv"&gt;OLLAMA_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;127.0.0.1:11435 ollama serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point the indexer at &lt;code&gt;:11435&lt;/code&gt;, leave &lt;code&gt;:11434&lt;/code&gt; for the chat model. Two processes, zero contention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. I deleted my warmup cron.&lt;/strong&gt; Before I understood any of this, my instinct was a job pinging the model every 4 minutes to keep it hot. It worked, sort of, and it also kept the GPU awake 24/7 for a model I use in two bursts a day. &lt;code&gt;OLLAMA_KEEP_ALIVE&lt;/code&gt; does the same job without a second moving part to debug at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did it cost, in numbers?
&lt;/h2&gt;

&lt;p&gt;One box, one workload, 24 hours before and 24 hours after.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requests logged&lt;/td&gt;
&lt;td&gt;1,180&lt;/td&gt;
&lt;td&gt;1,206&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model load events&lt;/td&gt;
&lt;td&gt;214&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold requests&lt;/td&gt;
&lt;td&gt;18.1%&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50 time to first token&lt;/td&gt;
&lt;td&gt;3.1s&lt;/td&gt;
&lt;td&gt;1.0s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 time to first token&lt;/td&gt;
&lt;td&gt;12.6s&lt;/td&gt;
&lt;td&gt;1.9s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron summarizer, cold rate&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chat generation speed&lt;/td&gt;
&lt;td&gt;42 tok/s&lt;/td&gt;
&lt;td&gt;42 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the one I keep pointing at. Tokens per second never changed. The model was never slow. Every second I'd spent for weeks blaming quantization, context length, and my GPU was time spent in &lt;code&gt;open()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Caveats, because I'd want them from you: this is one machine, one GPU, one traffic shape, one Ollama version. The &lt;code&gt;/v1&lt;/code&gt; behavior in particular is a version detail and might already differ on yours. The method transfers even if my numbers don't. Count loads, measure first-token latency, and read &lt;code&gt;ollama ps&lt;/code&gt; instead of trusting a config file.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what does Ollama keep_alive actually do?
&lt;/h2&gt;

&lt;p&gt;Ollama &lt;code&gt;keep_alive&lt;/code&gt; controls how long a model stays resident in memory after its last request, defaulting to 5 minutes, after which the next request pays a full cold load from disk — in my case 11.4s to first token instead of 0.9s. Set it server-side with &lt;code&gt;OLLAMA_KEEP_ALIVE&lt;/code&gt; rather than per-request, because the OpenAI-compatible &lt;code&gt;/v1&lt;/code&gt; endpoint ignored the body parameter in my testing. Verify with &lt;code&gt;ollama ps&lt;/code&gt; and by counting runner-start lines in the server log, not by reading your config. And resist &lt;code&gt;keep_alive: -1&lt;/code&gt; on multiple models sharing one GPU: pinning models that don't both fit forces partial CPU offload, which cost me 42 tok/s down to 6 tok/s and is far worse than the cold start you were trying to avoid.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>python</category>
    </item>
    <item>
      <title>vLLM Preemption: Why 1 in 50 Requests Restarts From Scratch</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Mon, 14 Sep 2026 16:55:15 +0000</pubDate>
      <link>https://dev.to/ji_ai/vllm-preemption-why-1-in-50-requests-restarts-from-scratch-32kk</link>
      <guid>https://dev.to/ji_ai/vllm-preemption-why-1-in-50-requests-restarts-from-scratch-32kk</guid>
      <description>&lt;p&gt;Our chat endpoint had a mean latency of 1.9s and a p99 of 11.4s.&lt;/p&gt;

&lt;p&gt;Same model. Same GPU. Same prompt template. &lt;code&gt;nvidia-smi&lt;/code&gt; showed 96% utilization and no memory pressure worth mentioning. Nothing crashed. Nothing retried. But roughly one request in fifty would stream a few tokens, freeze for six seconds mid-sentence, then finish normally like nothing happened.&lt;/p&gt;

&lt;p&gt;That freeze has a name: &lt;strong&gt;vLLM preemption&lt;/strong&gt;. The scheduler evicted a request that was already 80% done, threw away every KV block it had built up, and put it back in the queue to be prefilled again from token zero. My users paid for those tokens twice. So did my GPU.&lt;/p&gt;

&lt;p&gt;(Numbers in this post are from my own staging box, a single 48 GB card serving Llama-3.1-8B-Instruct in fp16. They are not published benchmarks, and yours will differ.)&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;vLLM preemption&lt;/strong&gt; happens when the KV cache pool runs out of free blocks while requests are still generating. The scheduler evicts in-flight requests to keep the batch moving.&lt;/li&gt;
&lt;li&gt;Default mode is &lt;code&gt;RECOMPUTE&lt;/code&gt;: the evicted request's KV cache is discarded entirely and re-prefilled later. Work already done is gone.&lt;/li&gt;
&lt;li&gt;It shows up as p99 latency spikes and mid-stream stalls, never as an error. Mean latency and GPU utilization both look healthy.&lt;/li&gt;
&lt;li&gt;Root cause is almost always &lt;code&gt;max_num_seqs&lt;/code&gt; (default 256) admitting far more concurrent sequences than your KV pool can hold at your real p95 output length.&lt;/li&gt;
&lt;li&gt;Fix: watch &lt;code&gt;vllm:num_preemptions_total&lt;/code&gt;, cap &lt;code&gt;max_num_seqs&lt;/code&gt; to what the pool actually sustains, and bound &lt;code&gt;max_tokens&lt;/code&gt; per request.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does vLLM preemption actually mean?
&lt;/h2&gt;

&lt;p&gt;vLLM preemption is the scheduler kicking a &lt;em&gt;running&lt;/em&gt; request out of the batch because there is no free KV cache block left to append its next token.&lt;/p&gt;

&lt;p&gt;This is not admission queueing. Admission queueing is fine and fair: requests sit in the waiting queue, you see them in &lt;code&gt;num_requests_waiting&lt;/code&gt;, first in first out. Preemption is the opposite. It punishes requests that already made it in, already burned prefill compute, already streamed tokens to a human who is watching.&lt;/p&gt;

&lt;p&gt;Here is the mechanism, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;PagedAttention stores KV in fixed-size blocks (16 tokens each by default), like pages in virtual memory.&lt;/li&gt;
&lt;li&gt;Every running sequence needs a fresh block roughly every 16 generated tokens.&lt;/li&gt;
&lt;li&gt;The pool is finite. It is whatever GPU memory is left after weights and activation profiling, governed by &lt;code&gt;gpu_memory_utilization&lt;/code&gt; (default 0.9).&lt;/li&gt;
&lt;li&gt;When a running sequence needs a block and none is free, the scheduler picks a victim from the running batch and preempts it.&lt;/li&gt;
&lt;li&gt;In &lt;code&gt;RECOMPUTE&lt;/code&gt; mode (the default), the victim's blocks are freed and the request goes back to the front of the waiting queue. Its generated text is kept; its KV cache is not. To resume, vLLM must prefill the original prompt &lt;em&gt;plus everything it already generated&lt;/em&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 is the expensive part. A request that had produced 900 tokens does not resume at token 900. It re-prefills ~900 extra tokens of context before it can emit token 901.&lt;/p&gt;

&lt;p&gt;vLLM does tell you. The log line reads roughly like: &lt;em&gt;sequence group is preempted because there is not enough KV cache space&lt;/em&gt;, followed by advice to raise &lt;code&gt;gpu_memory_utilization&lt;/code&gt; or tensor parallelism. If you are running with default log levels and only alerting on 5xx, you will never see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the KV cache run out when GPU memory looks fine?
&lt;/h2&gt;

&lt;p&gt;Because KV cache pressure is a function of &lt;strong&gt;live tokens&lt;/strong&gt;, not request count, and live tokens grow the whole time a request is generating.&lt;/p&gt;

&lt;p&gt;A request holding 4,000 tokens of context occupies 4,000 tokens' worth of KV. Ten of those cost the same as forty requests at 1,000 tokens. Your load test with fixed 128-token outputs told you nothing about the request that asks for a 2,000-token summary at 3pm.&lt;/p&gt;

&lt;p&gt;The math is simple enough to do on a napkin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Llama-3.1-8B-Instruct, fp16, GQA
&lt;/span&gt;&lt;span class="n"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kv_heads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;head_dim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

&lt;span class="n"&gt;bytes_per_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;layers&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;kv_heads&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;head_dim&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dtype_bytes&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes_per_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# 131072  == 128 KiB per token
&lt;/span&gt;
&lt;span class="n"&gt;kv_pool_gib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;                  &lt;span class="c1"&gt;# what was left after weights on my box
&lt;/span&gt;&lt;span class="n"&gt;max_live_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kv_pool_gib&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;bytes_per_token&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_live_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# 196,608   (~8,192 tokens per GiB)
&lt;/span&gt;
&lt;span class="c1"&gt;# my real traffic: ~2,000 prompt tokens, p95 output ~1,500
&lt;/span&gt;&lt;span class="n"&gt;live_per_request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_live_tokens&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;live_per_request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 56
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifty-six. That is how many sequences my pool could hold at p95 shape.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max_num_seqs&lt;/code&gt; defaults to &lt;strong&gt;256&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the scheduler cheerfully admitted up to 256 concurrent sequences into a pool that supports 56 of them once they grow up. Everything looked great for the first few hundred generated tokens, then the pool hit 100% and the evictions started. And because a preempted request comes back, re-prefills, and immediately competes for blocks again, it can get preempted a second time. That is the thrash.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does vLLM preemption wreck p99 instead of mean latency?
&lt;/h2&gt;

&lt;p&gt;Because preemption is rare and catastrophic, which is exactly the shape that hides in an average.&lt;/p&gt;

&lt;p&gt;Ninety-eight percent of requests never get chosen as a victim and run at normal speed. The two percent that do pay: the stall while they sit in the waiting queue, plus a full re-prefill of prompt + generated-so-far, plus whatever queueing they hit on the way back in. On my box those requests landed 4-6x their normal latency. Averaged over the whole window, the mean barely moved.&lt;/p&gt;

&lt;p&gt;GPU utilization actively lies to you here. Recompute is dense prefill work. The GPU is extremely busy doing it. Your dashboard reads "96% utilized, great throughput" while a meaningful slice of that throughput is tokens you already computed once.&lt;/p&gt;

&lt;p&gt;The metric that does not lie is the counter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8000/metrics | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'preemption|cache_usage|num_requests'&lt;/span&gt;
&lt;span class="c"&gt;# vllm:num_preemptions_total{...}      &amp;lt;- should be flat at 0&lt;/span&gt;
&lt;span class="c"&gt;# vllm:gpu_cache_usage_perc{...}       &amp;lt;- if this parks near 1.0, you are about to preempt&lt;/span&gt;
&lt;span class="c"&gt;# vllm:num_requests_running{...}&lt;/span&gt;
&lt;span class="c"&gt;# vllm:num_requests_waiting{...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vllm:num_preemptions_total&lt;/code&gt; is a counter. Alert on its &lt;em&gt;rate&lt;/em&gt;, not its value. Any sustained nonzero rate in production means you are paying for prefill twice.&lt;/p&gt;

&lt;p&gt;And watch &lt;code&gt;gpu_cache_usage_perc&lt;/code&gt;. If it sits pinned above ~0.9 during peak, you are one long request away from thrashing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you stop vLLM preemption?
&lt;/h2&gt;

&lt;p&gt;Cap concurrency at what the KV pool can actually sustain, and make queueing happen at the door instead of mid-generation.&lt;/p&gt;

&lt;p&gt;The config that fixed mine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm serve meta-llama/Llama-3.1-8B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-num-seqs&lt;/span&gt; 48 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 8192 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.92
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In priority order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lower &lt;code&gt;max_num_seqs&lt;/code&gt;.&lt;/strong&gt; This is the big one. Compute your sustainable concurrency from the napkin math above using your &lt;em&gt;p95&lt;/em&gt; live-token count, then set &lt;code&gt;max_num_seqs&lt;/code&gt; a bit under it. Excess load waits in the admission queue, which is fair, observable, and cheap. Yes, your &lt;code&gt;num_requests_waiting&lt;/code&gt; gauge will go up. That is the point: visible queueing beats invisible recompute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound &lt;code&gt;max_tokens&lt;/code&gt; per request.&lt;/strong&gt; An unbounded &lt;code&gt;max_tokens&lt;/code&gt; from a client is a request that can grow until it destabilizes everyone else's batch. Clamp it server-side. Most endpoints do not need 4,000 tokens of output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nudge &lt;code&gt;gpu_memory_utilization&lt;/code&gt; up.&lt;/strong&gt; Going from 0.90 to 0.92-0.94 buys real blocks. Do it in small steps and actually load-test, because the headroom left over is what absorbs activation spikes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep prefix caching on.&lt;/strong&gt; It is on by default in the V1 engine. When a preempted request comes back, freed blocks that have not been overwritten can still be hit on the recompute path, which softens the worst case. It reduces the cost of preemption; it does not prevent it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load-test with your real output-length distribution.&lt;/strong&gt; Fixed-length synthetic load is the reason this bug ships. Replay actual production prompt and completion lengths, or you are testing a workload that does not exist.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Swapping to CPU (&lt;code&gt;--swap-space&lt;/code&gt;) moves blocks over PCIe instead of recomputing. It trades one cost for another and it is a band-aid, not a fix. Reach for concurrency limits first.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why does one request in fifty restart from scratch?
&lt;/h2&gt;

&lt;p&gt;Because vLLM's scheduler will admit more concurrent sequences than your KV cache can hold once those sequences grow, and when the block pool runs dry it preempts a running request, discards its entire KV cache, and re-prefills it from the beginning later. The default &lt;code&gt;max_num_seqs&lt;/code&gt; of 256 is far above what most single-GPU deployments can sustain at real output lengths, so the failure only appears under load, only affects a small fraction of requests, and never raises an error. Check &lt;code&gt;vllm:num_preemptions_total&lt;/code&gt;. If it is climbing, cap &lt;code&gt;max_num_seqs&lt;/code&gt; to your measured sustainable concurrency and clamp &lt;code&gt;max_tokens&lt;/code&gt; per request. Your mean latency will barely change. Your p99 will fall off a cliff, in the good direction.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>performance</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>Nginx proxy_buffering Ate My LLM Stream: 11s to First Token</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Mon, 14 Sep 2026 04:54:15 +0000</pubDate>
      <link>https://dev.to/ji_ai/nginx-proxybuffering-ate-my-llm-stream-11s-to-first-token-4ii3</link>
      <guid>https://dev.to/ji_ai/nginx-proxybuffering-ate-my-llm-stream-11s-to-first-token-4ii3</guid>
      <description>&lt;p&gt;On my laptop the report streamed in like a little typewriter. Token by token, 0.4 seconds to the first character, exactly the experience I wanted.&lt;/p&gt;

&lt;p&gt;Then I deployed it. In production the page sat there doing absolutely nothing for eleven seconds, and then slammed all 1,800 tokens into the DOM in a single frame. Same code. Same model. Same prompt.&lt;/p&gt;

&lt;p&gt;I spent three days blaming the model. It was nginx proxy_buffering. And behind nginx there were three more layers doing the same thing, each one politely holding my tokens hostage for my own good.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If your LLM stream arrives all at once in production but streams fine locally, a proxy is buffering it. &lt;code&gt;proxy_buffering on&lt;/code&gt; is the nginx default and it will happily collect your entire SSE response before forwarding one byte.&lt;/li&gt;
&lt;li&gt;Turning off &lt;code&gt;proxy_buffering&lt;/code&gt; alone barely helped me: 11.4s to 9.1s. &lt;strong&gt;gzip was the bigger dam.&lt;/strong&gt; Compression buffers your event stream too.&lt;/li&gt;
&lt;li&gt;The full fix was four layers: nginx buffering, gzip, CDN transform, and my own token-chunking code. Final result: 0.7s to first visible token.&lt;/li&gt;
&lt;li&gt;Diagnose it in one command, not three days: &lt;code&gt;curl -N&lt;/code&gt; through the proxy, then &lt;code&gt;curl -N&lt;/code&gt; straight at the origin port. If the origin is fast and the proxy is slow, stop reading your Python.&lt;/li&gt;
&lt;li&gt;Bonus bug found on the way: &lt;code&gt;proxy_read_timeout&lt;/code&gt; defaults to 60s, which silently truncated every report that ran longer than a minute.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What was actually streaming here?
&lt;/h2&gt;

&lt;p&gt;The system is the written-report step of &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform I run (full disclosure: I built it). It runs a realistic voice interview, then generates a scored written report from the transcript. That report is 1,500 to 2,000 tokens of markdown, which takes long enough to generate that streaming isn't a nicety, it's the difference between "thinking" and "broken."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftd5zed80xfjpv7qjalsn.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftd5zed80xfjpv7qjalsn.jpg" alt="Preterview — an interview session in progress" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The stack is boring on purpose: FastAPI + &lt;code&gt;StreamingResponse&lt;/code&gt;, Server-Sent Events, uvicorn on 127.0.0.1:8000, nginx in front, a CDN in front of that. Nothing exotic. That's the point. Every layer in that list buffers by default, and every one of them thinks it's helping.&lt;/p&gt;

&lt;p&gt;Here's what a dead stream cost me in real user behavior: in the week before I fixed it, &lt;strong&gt;41 of 120 report sessions had a page reload before the report finished.&lt;/strong&gt; People assumed it had hung, because from the browser's point of view it had. Each reload kicked off another generation. I was paying twice to deliver a worse experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does nginx proxy_buffering break SSE token streaming?
&lt;/h2&gt;

&lt;p&gt;Because nginx proxy_buffering exists to protect your app server from slow clients. It reads the upstream response as fast as the upstream can produce it, parks it in memory (and then on disk), and drips it out to the client at the client's pace. For a 400KB JSON payload that's genuinely good engineering. Your Python worker gets freed immediately instead of babysitting someone on hotel wifi.&lt;/p&gt;

&lt;p&gt;For SSE it's a catastrophe, because the value of the response is entirely in its timing. nginx doesn't know that. It sees bytes. It fills a 4k buffer, and only when that buffer is full (or the upstream closes the connection) does the client see anything.&lt;/p&gt;

&lt;p&gt;So with &lt;code&gt;proxy_buffering on&lt;/code&gt;, the "stream" becomes: generate for eleven seconds, buffer, flush once. Which is exactly the shape of the bug I saw.&lt;/p&gt;

&lt;p&gt;The two fixes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/report/stream&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;proxy_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_cache&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;gzip&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;3600s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;chunked_transfer_encoding&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, better if you don't want to touch nginx config for every new endpoint, let the app declare it per response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StreamingResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;token_generator&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/event-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cache-Control&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no-cache, no-transform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Accel-Buffering&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keep-alive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; is nginx's own opt-out header, and it's the one I'd reach for first. It travels with the response, so a new streaming route works without a deploy of your proxy config.&lt;/p&gt;

&lt;p&gt;I lost an afternoon on this header because I set it on the wrong thing. I emitted it inside the first SSE event instead of on the HTTP response headers. It arrived as part of the body, nginx never saw it as a header, and the stream stayed dead. Headers go on the &lt;code&gt;StreamingResponse&lt;/code&gt;, not in the generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  What were the four layers, and what did each one cost?
&lt;/h2&gt;

&lt;p&gt;Four things buffered my tokens. Here's the measured time to first visible token in the browser, on the same 1,800-token report, fixing one layer at a time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;First visible token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Production baseline&lt;/td&gt;
&lt;td&gt;11.4s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;proxy_buffering off&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gzip excluded for &lt;code&gt;text/event-stream&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;3.2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDN: correct content-type + &lt;code&gt;no-transform&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.9s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;My own chunking removed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.7s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(Local dev, for reference)&lt;/td&gt;
&lt;td&gt;0.4s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: nginx proxy_buffering.&lt;/strong&gt; 11.4s to 9.1s. A 2.3 second improvement on a problem I was certain was 100% nginx. That gap is the whole lesson of this post: I almost reverted the fix because it "didn't work."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: gzip.&lt;/strong&gt; This is the one that gets people. gzip has to accumulate input before it can emit a compressed block, so a compressor sitting in your response path is a buffer whether or not you disabled the other buffer. &lt;code&gt;text/event-stream&lt;/code&gt; is not in nginx's default &lt;code&gt;gzip_types&lt;/code&gt;, so you'd think you're safe. I wasn't, because I'd added a wide &lt;code&gt;gzip_types&lt;/code&gt; list years earlier and copied it forward into every server block since. Check yours before you assume. Removing it: 9.1s to 3.2s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3: the CDN.&lt;/strong&gt; Anything that can transform your response body can hold it. In my case the stream was leaving my origin with a content-type the edge didn't recognize as streamable, because I was setting &lt;code&gt;media_type&lt;/code&gt; on an inner response object and the outer one defaulted to &lt;code&gt;application/json&lt;/code&gt;. Setting &lt;code&gt;text/event-stream&lt;/code&gt; correctly and sending &lt;code&gt;Cache-Control: no-cache, no-transform&lt;/code&gt; took it to 1.9s. If you're not sure, bypass the CDN with a direct DNS entry to your origin and measure again. Don't theorize about edge behavior, measure it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 4: me.&lt;/strong&gt; This was the embarrassing one. My generator wasn't yielding tokens, it was accumulating them until a "markdown-safe boundary" — a closed &lt;code&gt;**&lt;/code&gt;, a finished list item — so the UI never flashed a half-rendered bold marker. That felt clever. In practice it held 40 to 80 tokens at a time, and inside a heading or a fenced code block it held far longer. I was the fourth proxy in my own stack. I ripped it out and made the renderer tolerant of partial markdown instead, which is where that logic belonged. 1.9s to 0.7s.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you find which layer is buffering your stream?
&lt;/h2&gt;

&lt;p&gt;Bisect at the proxy boundary, with two curl calls. This takes about ninety seconds and would have saved me three days.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# through the proxy, from outside&lt;/span&gt;
curl &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'ttfb %{time_starttransfer}s\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://api.example.com/api/report/stream

&lt;span class="c"&gt;# straight at the origin, on the box, skipping nginx entirely&lt;/span&gt;
curl &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'ttfb %{time_starttransfer}s\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://127.0.0.1:8000/api/report/stream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;time_starttransfer&lt;/code&gt; is time to first byte, which for a stream is the only number that matters. &lt;code&gt;-N&lt;/code&gt; disables curl's own output buffering, and forgetting it is the classic false positive: curl will happily make a perfectly healthy stream look broken.&lt;/p&gt;

&lt;p&gt;My two numbers were 0.5s at the origin and 11.4s through the proxy. That single comparison rules out the model, your prompt, your token generator, your event loop, and your frontend, all at once. If the origin is fast, no amount of staring at your Python is going to help you.&lt;/p&gt;

&lt;p&gt;For the layers above nginx, keep bisecting the same way: hit the origin's public IP directly to skip the CDN, then compare with the CDN in path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you just turn proxy_buffering off everywhere?
&lt;/h2&gt;

&lt;p&gt;No, and I tried, which is how I learned why not. I put &lt;code&gt;proxy_buffering off&lt;/code&gt; in the &lt;code&gt;http&lt;/code&gt; block because it was one line instead of many, and file uploads and large JSON responses got measurably worse under concurrency. That's the feature working as designed: with buffering off, a slow client's pace becomes your app worker's pace, and your workers spend their time trickling bytes to phones on bad connections instead of serving requests.&lt;/p&gt;

&lt;p&gt;Scope it to the streaming location, or use &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; per response. Buffering is the right default for almost every route you have. It's wrong for exactly the routes where time-to-first-byte is the product.&lt;/p&gt;

&lt;p&gt;One more thing to set while you're in that block: &lt;code&gt;proxy_read_timeout&lt;/code&gt; defaults to 60 seconds, measured between reads from upstream. Long report generations hit it and the connection closed at almost exactly 60.0s, mid-sentence, with no error anywhere in my application logs. Six of those 120 sessions had silently truncated reports and I'd never noticed, because a truncated markdown report still looks like a report.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what actually fixes an LLM stream that arrives all at once?
&lt;/h2&gt;

&lt;p&gt;If your tokens stream locally and arrive in one lump in production, the cause is buffering between your app and the browser, not your model or your generator. Set &lt;code&gt;proxy_buffering off&lt;/code&gt; (or send &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;) on the streaming route, disable gzip for &lt;code&gt;text/event-stream&lt;/code&gt;, send &lt;code&gt;Content-Type: text/event-stream&lt;/code&gt; with &lt;code&gt;Cache-Control: no-cache, no-transform&lt;/code&gt; so your CDN passes it through untransformed, raise &lt;code&gt;proxy_read_timeout&lt;/code&gt; well past your longest generation, and then check whether your own code is batching tokens before it yields them. Confirm each layer with &lt;code&gt;curl -N&lt;/code&gt; through the proxy versus straight at the origin. Four layers, 11.4 seconds to 0.7, and three of the four were defaults I never chose.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>tooling</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>git rerere: Stop Resolving the Same Merge Conflict Twice</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 13 Sep 2026 16:53:09 +0000</pubDate>
      <link>https://dev.to/ji_ai/git-rerere-stop-resolving-the-same-merge-conflict-twice-d7h</link>
      <guid>https://dev.to/ji_ai/git-rerere-stop-resolving-the-same-merge-conflict-twice-d7h</guid>
      <description>&lt;p&gt;Ninth rebase of a three-week-old branch onto &lt;code&gt;main&lt;/code&gt;. Same file. Same forty lines of conflict markers. Same resolution I had already typed eight times, character for character.&lt;/p&gt;

&lt;p&gt;Git watched me do it every single time and never once offered to help.&lt;/p&gt;

&lt;p&gt;Except it can. &lt;code&gt;git rerere&lt;/code&gt; has shipped inside git for roughly two decades, it is off by default, and almost nobody turns it on. It stands for &lt;strong&gt;reuse recorded resolution&lt;/strong&gt;: git memorizes how you resolved a conflicted hunk and silently replays that resolution the next time the exact same hunk shows up.&lt;/p&gt;

&lt;p&gt;That is the good news. The rest of this post is the part that actually matters: &lt;em&gt;how&lt;/em&gt; git rerere decides two conflicts are "the same," why it misses conflicts you swear are identical, and the case where it confidently replays a resolution that is now wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git config --global rerere.enabled true&lt;/code&gt; is the whole setup. Git then records every conflict resolution you commit into &lt;code&gt;.git/rr-cache/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Next time the identical conflict appears, git resolves the file in your working tree and prints &lt;code&gt;Resolved '&amp;lt;file&amp;gt;' using previous resolution.&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;rerere keys on a &lt;strong&gt;normalized preimage&lt;/strong&gt; of the conflicted hunk, not on the filename or the commit. Change the surrounding context lines and it becomes a different conflict with a different ID, and rerere goes quiet.&lt;/li&gt;
&lt;li&gt;It does &lt;strong&gt;not&lt;/strong&gt; make rebase hands-free. Git still stops, the path still shows as unmerged, you still run &lt;code&gt;git add&lt;/code&gt; and &lt;code&gt;git rebase --continue&lt;/code&gt;. Add &lt;code&gt;rerere.autoUpdate true&lt;/code&gt; to skip the &lt;code&gt;git add&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The footgun: rerere will happily replay a &lt;em&gt;stale&lt;/em&gt; resolution. &lt;code&gt;git rerere forget &amp;lt;path&amp;gt;&lt;/code&gt; while the conflict is on screen is the escape hatch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does git rerere actually do?
&lt;/h2&gt;

&lt;p&gt;git rerere records the before-and-after of a conflict you resolved by hand, then reapplies that resolution automatically when the same conflict reappears.&lt;/p&gt;

&lt;p&gt;Mechanically, git keeps a cache directory at &lt;code&gt;.git/rr-cache/&lt;/code&gt;. When a merge, rebase or cherry-pick hits a conflict, git computes an ID for each conflicted hunk and writes the conflicted text to &lt;code&gt;.git/rr-cache/&amp;lt;id&amp;gt;/preimage&lt;/code&gt;. When you finish resolving and commit, git writes your resolved text to &lt;code&gt;.git/rr-cache/&amp;lt;id&amp;gt;/postimage&lt;/code&gt;. The mapping from conflict ID to current state also lives in &lt;code&gt;.git/MERGE_RR&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Next time a conflict hashes to an ID that already has a &lt;code&gt;postimage&lt;/code&gt;, git drops your recorded resolution straight into the working tree.&lt;/p&gt;

&lt;p&gt;This is exactly the shape of the long-lived-branch problem. You rebase, you hit the conflict, you resolve, you rebase again a week later, the same two hunks fight again. First resolution is real work. Every replay after that is free.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I turn on git rerere?
&lt;/h2&gt;

&lt;p&gt;One line, globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; rerere.enabled &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two optional settings worth knowing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# stage rerere-resolved files automatically instead of leaving them unmerged&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; rerere.autoUpdate &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also a quiet side door: if a &lt;code&gt;.git/rr-cache&lt;/code&gt; directory exists, git treats rerere as enabled even without the config flag. That surprises people who inherited a repo from a teammate's tarball and wondered why conflicts were pre-solving themselves.&lt;/p&gt;

&lt;p&gt;Here is a 60-second reproduction you can paste into a scratch directory and watch work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;rerere-demo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;rerere-demo
git init &lt;span class="nt"&gt;-q&lt;/span&gt;
git config rerere.enabled &lt;span class="nb"&gt;true

printf&lt;/span&gt; &lt;span class="s1"&gt;'version = 1\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; config.txt
git add &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-qm&lt;/span&gt; init
git branch &lt;span class="nt"&gt;-M&lt;/span&gt; main

git switch &lt;span class="nt"&gt;-qc&lt;/span&gt; feature
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'version = 2-feature\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; config.txt
git commit &lt;span class="nt"&gt;-qam&lt;/span&gt; feature

git switch &lt;span class="nt"&gt;-q&lt;/span&gt; main
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'version = 2-main\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; config.txt
git commit &lt;span class="nt"&gt;-qam&lt;/span&gt; main

git merge feature          &lt;span class="c"&gt;# CONFLICT, as designed&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'version = 2-merged\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; config.txt
git add config.txt
git commit &lt;span class="nt"&gt;-qm&lt;/span&gt; merged      &lt;span class="c"&gt;# &amp;lt;- rerere records the resolution here&lt;/span&gt;

git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; HEAD~1    &lt;span class="c"&gt;# pretend that merge never happened&lt;/span&gt;
git merge feature
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last &lt;code&gt;git merge&lt;/code&gt; prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Resolved 'config.txt' using previous resolution.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;config.txt&lt;/code&gt; already contains &lt;code&gt;version = 2-merged&lt;/code&gt;. You never retyped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does git rerere miss conflicts that look identical?
&lt;/h2&gt;

&lt;p&gt;Because rerere keys on the content of the conflicted hunk plus its surrounding context, not on the file path or the branches involved. Edit a line near the conflict and you have produced a different preimage, a different ID, and rerere has no recorded resolution for it.&lt;/p&gt;

&lt;p&gt;This is the single biggest source of "rerere is broken" complaints, and it is not a bug. The cache is keyed by what the conflict &lt;em&gt;looks like&lt;/em&gt;. Shift the context and you get a cache miss.&lt;/p&gt;

&lt;p&gt;Two useful consequences:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It survives merge direction.&lt;/strong&gt; rerere normalizes the two sides of the conflict, so a resolution you recorded merging &lt;code&gt;feature&lt;/code&gt; into &lt;code&gt;main&lt;/code&gt; is still recognized when the same hunk shows up merging &lt;code&gt;main&lt;/code&gt; into &lt;code&gt;feature&lt;/code&gt;. This is why the classic "test-merge your topic branch repeatedly, throw the merge away, merge for real at the end" workflow works so well with rerere on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not survive reformatting.&lt;/strong&gt; Run Prettier, Black, or gofmt over the neighborhood and every recorded resolution in that region turns into a miss.&lt;/p&gt;

&lt;p&gt;Three commands tell you what rerere is thinking, and you can run them mid-conflict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git rerere status     &lt;span class="c"&gt;# which paths rerere has a preimage for&lt;/span&gt;
git rerere remaining  &lt;span class="c"&gt;# which paths are still yours to solve&lt;/span&gt;
git rerere diff       &lt;span class="c"&gt;# what changed between the conflict and the current state&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git rerere remaining&lt;/code&gt; is the one I actually use. During a big rebase it tells me, in one line, which files are still real work and which ones the cache already handled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does git rerere make rebase automatic?
&lt;/h2&gt;

&lt;p&gt;No, and this catches everyone. rerere resolves the &lt;em&gt;content&lt;/em&gt;, it does not resolve the &lt;em&gt;operation&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;With plain &lt;code&gt;rerere.enabled true&lt;/code&gt;, a rebase that hits a recorded conflict will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;stop, exactly like a normal conflict,&lt;/li&gt;
&lt;li&gt;write the recorded resolution into the working tree,&lt;/li&gt;
&lt;li&gt;leave the path listed under &lt;strong&gt;Unmerged paths&lt;/strong&gt; in &lt;code&gt;git status&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the file on disk is already correct while git still calls it conflicted. You run &lt;code&gt;git add &amp;lt;file&amp;gt;&lt;/code&gt; and &lt;code&gt;git rebase --continue&lt;/code&gt; and move on.&lt;/p&gt;

&lt;p&gt;Turning on &lt;code&gt;rerere.autoUpdate&lt;/code&gt; stages those paths for you, which collapses the ritual to just &lt;code&gt;git rebase --continue&lt;/code&gt;. The same behavior is available per-invocation with &lt;code&gt;git merge --rerere-autoupdate&lt;/code&gt; and &lt;code&gt;git rebase --rerere-autoupdate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I keep autoUpdate on globally, with one habit attached: I read the &lt;code&gt;Resolved '&amp;lt;file&amp;gt;' using previous resolution.&lt;/code&gt; lines in the scrollback before continuing. Which brings us to the dangerous part.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does git rerere apply the wrong resolution?
&lt;/h2&gt;

&lt;p&gt;When the conflicted hunk still hashes to the same ID but the correct answer has changed. rerere replays what you did last time. It has no idea whether that is still right.&lt;/p&gt;

&lt;p&gt;The realistic version: months ago you resolved a conflict in a config block by keeping your branch's value. Since then the team decided &lt;code&gt;main&lt;/code&gt;'s value is now authoritative. The hunk is textually unchanged, so rerere matches it, replays "keep mine," and with autoUpdate on it stages the file. If you continue without reading the output, you have just silently reverted a decision.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;git rerere forget&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# while the conflict is on screen&lt;/span&gt;
git rerere forget path/to/file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That drops the recorded resolution for the current conflict in that path and hands you the conflict markers back. If the file was already auto-resolved and staged, restore the markers first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git checkout &lt;span class="nt"&gt;--conflict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge path/to/file
git rerere forget path/to/file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more housekeeping facts. &lt;code&gt;git gc&lt;/code&gt; prunes the cache on a timer, controlled by &lt;code&gt;gc.rerereResolved&lt;/code&gt; (default 60 days for resolutions that were used) and &lt;code&gt;gc.rerereUnresolved&lt;/code&gt; (default 15 days for conflicts you never finished). And &lt;code&gt;.git/rr-cache&lt;/code&gt; is strictly local: it is not cloned, not pushed, not fetched. If you want a shared team cache you have to copy or symlink the directory yourself, which I would only do with a team that agrees on what "correct resolution" means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you turn git rerere on?
&lt;/h2&gt;

&lt;p&gt;Yes, with autoUpdate and one habit. The cost is a single config line and a directory inside &lt;code&gt;.git&lt;/code&gt; that a &lt;code&gt;gc&lt;/code&gt; run keeps trimmed. The benefit shows up the moment you maintain a branch that outlives a sprint, keep a long-running fork in sync, or do the test-merge-then-discard dance.&lt;/p&gt;

&lt;p&gt;The habit: when you see &lt;code&gt;Resolved '&amp;lt;file&amp;gt;' using previous resolution.&lt;/code&gt;, glance at the diff before you continue. rerere is a cache, and like every cache the failure mode is not "no answer," it is "a confident stale answer."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So what is git rerere?&lt;/strong&gt; git rerere is a built-in git feature that records how you resolved a merge conflict and automatically replays that resolution the next time the identical conflicted hunk appears in a merge, rebase or cherry-pick. Enable it with &lt;code&gt;git config --global rerere.enabled true&lt;/code&gt;; add &lt;code&gt;rerere.autoUpdate true&lt;/code&gt; so resolved paths are staged for you. It keys on the normalized content of the conflicted hunk, so changing surrounding lines makes it miss, and it will replay an out-of-date resolution without warning, which &lt;code&gt;git rerere forget &amp;lt;path&amp;gt;&lt;/code&gt; undoes while the conflict is still open.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tooling</category>
      <category>cli</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Claude Code in 4 Git Worktrees: 23 Tasks, 9 Collisions, 1.5x Speed</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 13 Sep 2026 04:52:12 +0000</pubDate>
      <link>https://dev.to/ji_ai/claude-code-in-4-git-worktrees-23-tasks-9-collisions-15x-speed-155d</link>
      <guid>https://dev.to/ji_ai/claude-code-in-4-git-worktrees-23-tasks-9-collisions-15x-speed-155d</guid>
      <description>&lt;p&gt;On Wednesday at 11:40am, four Claude Code sessions were running in four terminal tabs, each in its own git worktree, and all four of them were fighting over port 3000.&lt;/p&gt;

&lt;p&gt;Agent 2 would start the Next.js dev server. Agent 4 would see "port in use", decide the old process was stale, and kill it. Agent 2 would then report that its Playwright check "failed due to a connection refused error, likely a flaky environment" and try again. They did this to each other for about 20 minutes before I noticed.&lt;/p&gt;

&lt;p&gt;That was day 3 of a one-week experiment: run &lt;strong&gt;Claude Code in parallel git worktrees&lt;/strong&gt; on a real side project and see if four agents really means four times the output. It doesn't. Here's what it actually means, with the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Running Claude Code in 4 git worktrees for 5 working days, I queued 23 tasks. 17 shipped, 4 needed heavy rework, 2 got thrown away.&lt;/li&gt;
&lt;li&gt;9 of the 23 branches collided with another agent's work: 5 textual merge conflicts, 4 clean merges that broke &lt;code&gt;main&lt;/code&gt; or staging anyway.&lt;/li&gt;
&lt;li&gt;My throughput went from 11 shipped tasks the week before (one agent, sequential) to 17. That's 1.5x, not 4x.&lt;/li&gt;
&lt;li&gt;The bottleneck moved from the agent to me. I spent 6.5 hours reviewing diffs, more than any single agent spent working.&lt;/li&gt;
&lt;li&gt;What fixed most of it: per-worktree ports, one "migrations lane", file-ownership rules per task, and capping at 3 agents.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What was the setup?
&lt;/h2&gt;

&lt;p&gt;The setup was one monorepo, four worktrees, four independent Claude Code sessions, and me as the merge queue. The project is a small SaaS I run on the side: a Next.js frontend, a FastAPI backend, Postgres with Alembic migrations, and Redis for caching.&lt;/p&gt;

&lt;p&gt;Creating the worktrees is the easy part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../app-wt1 &lt;span class="nt"&gt;-b&lt;/span&gt; feat/billing-export
git worktree add ../app-wt2 &lt;span class="nt"&gt;-b&lt;/span&gt; feat/team-invites
git worktree add ../app-wt3 &lt;span class="nt"&gt;-b&lt;/span&gt; fix/search-pagination
git worktree add ../app-wt4 &lt;span class="nt"&gt;-b&lt;/span&gt; feat/audit-log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;cd&lt;/code&gt; into each one, run &lt;code&gt;claude&lt;/code&gt;, and hand it a task from a list. Each worktree shares the same &lt;code&gt;.git&lt;/code&gt; object store, so there's no re-cloning, but each has its own checked-out files, its own branch, and (as I learned) its own need for &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My rule for the week: every task gets a branch, every branch goes through CI, and I merge to &lt;code&gt;main&lt;/code&gt; myself. No agent touches &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many tasks actually shipped?
&lt;/h2&gt;

&lt;p&gt;Out of 23 tasks, 17 shipped with light edits, 4 needed heavy rework, and 2 were deleted. For comparison, the previous week I used a single Claude Code session sequentially and shipped 11 tasks.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Merged clean, shipped&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merge conflict, resolved quickly, shipped&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heavy rework (I rewrote 30%+ of the diff)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thrown away&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 2 throwaways were both cases where the agent solved the task in a way that made another in-flight branch pointless. Agent 3 refactored the search query builder "while it was in there", which invalidated the approach Agent 1 was halfway through for billing export filters. Neither agent knew the other existed. Why would they?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do parallel Claude Code agents collide?
&lt;/h2&gt;

&lt;p&gt;Parallel Claude Code agents collide because git worktrees isolate files, not shared resources or intent. Each agent sees a clean, consistent repo and makes locally correct decisions. The collisions happen at everything the worktrees don't isolate: ports, databases, lockfiles, shared utilities, and naming conventions.&lt;/p&gt;

&lt;p&gt;Here are the 9 collisions, grouped by what caused them.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Lockfile conflicts (4 of 9)
&lt;/h3&gt;

&lt;p&gt;Three agents added npm dependencies and one bumped a Python package. &lt;code&gt;package-lock.json&lt;/code&gt; conflicts are textually huge and semantically boring. I resolved all four by taking &lt;code&gt;main&lt;/code&gt;'s lockfile and re-running &lt;code&gt;npm install&lt;/code&gt; on the branch. Annoying, about 10 minutes each, never dangerous.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Duplicate Alembic migrations (2 of 9)
&lt;/h3&gt;

&lt;p&gt;This one is nasty. Agent 2 (team invites) and Agent 4 (audit log) both needed a schema change. Both ran &lt;code&gt;alembic revision --autogenerate&lt;/code&gt; from the same parent revision. Both branches merged without a single git conflict, because the migration files had different random revision IDs and different filenames.&lt;/p&gt;

&lt;p&gt;Then CI on &lt;code&gt;main&lt;/code&gt; ran &lt;code&gt;alembic upgrade head&lt;/code&gt; and got this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Multiple head revisions are present for given argument 'head'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two heads. Fixable with &lt;code&gt;alembic merge heads&lt;/code&gt;, but the first time it happened, it happened on &lt;code&gt;main&lt;/code&gt;, and my deploy was blocked until I fixed it. The second time I caught it on the branch because I'd added &lt;code&gt;alembic heads&lt;/code&gt; to CI with a check that the output is exactly one line.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Route index conflict (1 of 9)
&lt;/h3&gt;

&lt;p&gt;Two agents registered new API routers by appending a line to the same &lt;code&gt;routers/__init__.py&lt;/code&gt;. Classic adjacent-line conflict. Two minutes to fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The two that git couldn't see (2 of 9)
&lt;/h3&gt;

&lt;p&gt;These are the ones that scared me, because both merged clean and both passed CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rename.&lt;/strong&gt; Agent 1 renamed &lt;code&gt;formatPrice()&lt;/code&gt; to &lt;code&gt;formatCurrency()&lt;/code&gt; across the frontend and updated all 14 call sites that existed on its branch. Meanwhile Agent 4 added 3 new call sites to &lt;code&gt;formatPrice()&lt;/code&gt;. Git merged it happily. TypeScript caught it on &lt;code&gt;main&lt;/code&gt;. Fine, that's what a typechecker is for, but &lt;code&gt;main&lt;/code&gt; was red for 25 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cache key.&lt;/strong&gt; This one reached staging. Agent 2 cached team membership under &lt;code&gt;user:{id}&lt;/code&gt;. Agent 4 cached audit-log summaries under &lt;code&gt;user:{id}&lt;/code&gt;. Different modules, different files, zero textual overlap, unit tests all green because each test suite mocked Redis on its own. On staging, the invite page tried to parse an audit summary as a membership list and threw a 500.&lt;/p&gt;

&lt;p&gt;No merge tool would have caught this. The only defense is a convention (I now namespace keys by module: &lt;code&gt;teams:user:{id}&lt;/code&gt;) written somewhere every agent reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about the environment problems?
&lt;/h2&gt;

&lt;p&gt;Environment problems cost more wall-clock time than merge conflicts did. Git worktrees give you a fresh checkout, and a fresh checkout is missing everything your &lt;code&gt;.gitignore&lt;/code&gt; hides.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ports.&lt;/strong&gt; All four dev servers defaulted to 3000 and the API to 8000. Agents killed each other's processes and blamed "flakiness". The fix was a tiny script I run when creating a worktree:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# wt-init.sh &amp;lt;n&amp;gt;&lt;/span&gt;
&lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PORT=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="m"&gt;3000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; n&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .env.local
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"API_PORT=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="m"&gt;8000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; n&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .env.local
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"DATABASE_URL=postgresql://localhost/app_wt&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .env.local
createdb &lt;span class="s2"&gt;"app_wt&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--silent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One database.&lt;/strong&gt; Before I added per-worktree databases, Agent 3 ran a test fixture that truncated the &lt;code&gt;users&lt;/code&gt; table while Agent 2 was manually testing invites. Agent 2 spent a while "debugging" why its freshly created user vanished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk.&lt;/strong&gt; Each worktree's &lt;code&gt;node_modules&lt;/code&gt; came out to about 1.1GB. Four of them plus build caches ate roughly 6GB. Not a crisis, but I didn't expect it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What did it cost?
&lt;/h2&gt;

&lt;p&gt;I'm on a flat subscription plan, so the dollar cost was fixed, but the usage limit was not. Four concurrent sessions hit my usage limit on day 3 at 2:40pm, and I lost the rest of that afternoon. On days 4 and 5, with three agents instead of four, I didn't hit it.&lt;/p&gt;

&lt;p&gt;The bigger cost was my attention. I logged my review time: &lt;strong&gt;6.5 hours&lt;/strong&gt; across the week, over 23 diffs with a median size of 214 changed lines. The week before, with one agent, review took about 3 hours for 11 tasks. Per task it's similar. The problem is the arrival pattern: four branches landing within the same hour means context switching across four unrelated features, and that's where I made mistakes. The cache key bug went past me in review. I read that diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is running multiple Claude Code agents in parallel worth it?
&lt;/h2&gt;

&lt;p&gt;Yes, but at 3 agents with strict lanes, not 4 agents on a shared codebase free-for-all. The speedup is real (1.5x in shipped tasks for me) but it's capped by human review and by how independent your tasks really are.&lt;/p&gt;

&lt;p&gt;Here's what I changed for week two:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cap at 3 agents.&lt;/strong&gt; Four exceeded both my usage limit and my review capacity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One migrations lane.&lt;/strong&gt; Only one worktree at a time is allowed to create an Alembic revision. Every other task prompt says: "Do not create migrations. If you need a schema change, stop and tell me."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File ownership in the prompt.&lt;/strong&gt; Each task lists directories the agent may edit. "You may modify &lt;code&gt;app/billing/&lt;/code&gt; and &lt;code&gt;web/app/billing/&lt;/code&gt;. Anything else, ask first." Claude Code mostly respects this, and when it doesn't, the diff makes it obvious.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No opportunistic refactors.&lt;/strong&gt; A line in &lt;code&gt;CLAUDE.md&lt;/code&gt;: "Do not rename or refactor shared utilities unless the task says to." This alone would have prevented both the rename break and one of the throwaways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebase before review.&lt;/strong&gt; Each agent runs &lt;code&gt;git fetch &amp;amp;&amp;amp; git rebase origin/main&lt;/code&gt; and re-runs tests before telling me it's done. Conflicts get resolved by the agent that has the context, not by me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conventions file.&lt;/strong&gt; Cache key namespacing, route registration, env var naming. Written down once, read by every session.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Week two isn't over, but after three days: 11 tasks shipped, 1 collision (a lockfile), zero red &lt;code&gt;main&lt;/code&gt; builds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;p&gt;Running Claude Code in parallel git worktrees does not multiply your output by the number of agents. In my week with 4 agents on a real monorepo, 23 tasks produced 17 shipped features (1.5x my single-agent week), 9 branches collided, and 4 of those collisions merged clean in git while breaking the build or behavior. Worktrees isolate files, not ports, databases, migrations, shared names, or intent, so the gains come from partitioning work so agents never touch the same resources, and from keeping the agent count at or below what one human can actually review.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>devops</category>
    </item>
    <item>
      <title>Prompt Caching: Why cache_control Writes But Never Reads</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sat, 12 Sep 2026 16:50:22 +0000</pubDate>
      <link>https://dev.to/ji_ai/prompt-caching-why-cachecontrol-writes-but-never-reads-5c57</link>
      <guid>https://dev.to/ji_ai/prompt-caching-why-cachecontrol-writes-but-never-reads-5c57</guid>
      <description>&lt;p&gt;I turned on prompt caching for an agent loop that resends a 12K-token system prompt on every turn. Obvious win, right? Input tokens are the whole bill in a tool loop.&lt;/p&gt;

&lt;p&gt;The bill went up.&lt;/p&gt;

&lt;p&gt;Not a little. Roughly a quarter. And nothing in the logs looked wrong. Every request returned 200. Latency was the same. The only place the truth was written down was the &lt;code&gt;usage&lt;/code&gt; object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;cache_creation_input_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12184&lt;/span&gt;
&lt;span class="na"&gt;cache_read_input_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="na"&gt;input_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;291&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve thousand tokens written to cache. Zero read back. Every single request. I paid the write premium 40 times in a row and never once collected. Prompt caching has exactly one invariant, and I had broken it in the most boring way possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompt caching is a prefix match.&lt;/strong&gt; The cache key is the exact bytes of the rendered prompt up to each &lt;code&gt;cache_control&lt;/code&gt; breakpoint. One changed byte at position N invalidates every breakpoint at or after N.&lt;/li&gt;
&lt;li&gt;Render order is &lt;code&gt;tools&lt;/code&gt; → &lt;code&gt;system&lt;/code&gt; → &lt;code&gt;messages&lt;/code&gt;. Anything volatile (timestamps, session IDs, the user's actual question) must sit &lt;em&gt;after&lt;/em&gt; your last breakpoint, not before it.&lt;/li&gt;
&lt;li&gt;Cache writes cost &lt;strong&gt;1.25×&lt;/strong&gt; base input (2× for the 1-hour TTL); reads cost about &lt;strong&gt;0.1×&lt;/strong&gt;. A cache that only ever writes is not a cache, it is a 25% surcharge.&lt;/li&gt;
&lt;li&gt;Ground truth is &lt;code&gt;usage.cache_read_input_tokens&lt;/code&gt;. If it is 0 across repeated requests with "identical" prompts, something upstream is rewriting your prefix.&lt;/li&gt;
&lt;li&gt;Two silent killers that produce byte-identical payloads and still miss: the &lt;strong&gt;20-position lookback window&lt;/strong&gt; in long tool turns, and &lt;strong&gt;parallel fan-out&lt;/strong&gt;, where an entry is only readable after the first response starts streaming.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does prompt caching never read from the cache?
&lt;/h2&gt;

&lt;p&gt;Because your &lt;code&gt;cache_control&lt;/code&gt; breakpoint is placed after content that changes every request, so each request writes a brand-new entry that nothing will ever match again.&lt;/p&gt;

&lt;p&gt;The mental model that fixes this: the cache is not a key-value store keyed on "my system prompt." It is a prefix match over the rendered request. The API renders &lt;code&gt;tools&lt;/code&gt;, then &lt;code&gt;system&lt;/code&gt;, then &lt;code&gt;messages&lt;/code&gt;, hashes the byte stream up to each breakpoint, and looks for an existing entry. First divergence wins. Everything downstream of it is cold.&lt;/p&gt;

&lt;p&gt;So all three of these are the same bug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1. The dynamic header
&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are a support agent. Current time: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;PLAYBOOK&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;# PLAYBOOK is 11K stable tokens sitting behind a string that changes every request.
&lt;/span&gt;
&lt;span class="c1"&gt;# 2. The nondeterministic serializer
&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Schema:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# no sort_keys=True
# Same dict, different key order, different bytes.
&lt;/span&gt;
&lt;span class="c1"&gt;# 3. The per-user tool list
&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_tools_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# tools render at position 0
# Nothing caches across users, ever.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My case was #1, wearing a disguise: the timestamp was injected by a helper three call frames away that I had written months earlier for logging.&lt;/p&gt;

&lt;p&gt;The pricing turns this from "suboptimal" into "actively worse than doing nothing." Writes are 1.25× base input price, reads are roughly 0.1×. Two requests that share a prefix break even (1.25 + 0.1 = 1.35 versus 2.0 uncached). One request that writes and is never read is 1.25× for nothing. Forty of them is a line item.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I tell whether prompt caching is working?
&lt;/h2&gt;

&lt;p&gt;Read three fields off &lt;code&gt;usage&lt;/code&gt; and remember that they partition your prompt: &lt;code&gt;input_tokens&lt;/code&gt; is only the &lt;em&gt;uncached remainder&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;total prompt tokens = input_tokens
                    + cache_creation_input_tokens
                    + cache_read_input_tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last part trips people up in both directions. An agent that ran for an hour showing &lt;code&gt;input_tokens: 4200&lt;/code&gt; is not magically cheap, and it is not broken either. Check the sum.&lt;/p&gt;

&lt;p&gt;In a healthy multi-turn loop, the shape looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cache_read_input_tokens&lt;/code&gt; covers the whole prior prefix and grows turn over turn.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cache_creation_input_tokens&lt;/code&gt; is small, roughly last turn's output plus the newly appended input, because writes only bill the delta past the highest hit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;input_tokens&lt;/code&gt; is just the tail after your last breakpoint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; is instead near the full conversation size on every turn, the prefix is being rewritten upstream.&lt;/p&gt;

&lt;p&gt;Make this a standing check, not a one-time look. The expensive failure mode in production is not a bad first implementation. It is a working implementation that regresses six months later, when someone adds a feature flag to the system prompt, and nothing errors, and the bill just drifts. One integration test earns its keep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;r1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;r2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# byte-identical
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_read_input_tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache prefix broke&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To localize a break, log several consecutive request bodies and diff adjacent pairs. In a growing conversation the tail legitimately differs, so what you are checking is the &lt;em&gt;overlap&lt;/em&gt;: the previous request's prompt should reappear unchanged as a prefix of the next. Strip &lt;code&gt;cache_control&lt;/code&gt; markers before diffing, since the moving marker always differs and is not the culprit. The first divergence inside the overlap is your invalidator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where should the cache_control breakpoint go?
&lt;/h2&gt;

&lt;p&gt;At the end of the &lt;strong&gt;shared&lt;/strong&gt; portion of the prompt, never at the end of the whole prompt.&lt;/p&gt;

&lt;p&gt;This is the mistake that produced my zero-read logs, and it is the same mistake automatic caching makes on your behalf. A top-level &lt;code&gt;cache_control&lt;/code&gt; on the request places one breakpoint on the last cacheable block and slides it forward as the conversation grows. Perfect for a chat thread. Wrong for the pattern where a big fixed preamble is followed by a unique per-request question, because the breakpoint lands after the unique tail, so every request writes a distinct entry over bytes nobody will read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RETRIEVED_DOCS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# 9K shared tokens
&lt;/span&gt;     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_control&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ephemeral&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;       &lt;span class="c1"&gt;# breakpoint HERE
&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;             &lt;span class="c1"&gt;# unique, unmarked, after
&lt;/span&gt;&lt;span class="p"&gt;]}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four more placement rules worth internalizing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Freeze the system prompt.&lt;/strong&gt; Date, mode, user name and feature flags do not belong at the front of the prefix. On Opus 5 and Opus 4.8 you can append &lt;code&gt;{"role": "system", "content": "..."}&lt;/code&gt; inside &lt;code&gt;messages[]&lt;/code&gt; instead, which sits &lt;em&gt;after&lt;/em&gt; the cached history and leaves it intact. Otherwise put it in a user turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serialize tools deterministically&lt;/strong&gt; and do not add, remove or reorder them mid-conversation. Tools render at position 0, so a reorder is a full rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caches are model-scoped.&lt;/strong&gt; Switching models mid-loop for a "cheap" side task forfeits the whole prefix. Give the subagent its own thread instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimum cacheable prefix is model-dependent and not monotonic&lt;/strong&gt; (512 tokens on the newest models, 1024 on Opus 4.8 and Sonnet 5, 4096 on Opus 4.6 and Haiku 4.5). Below the minimum you get no error, just &lt;code&gt;cache_creation_input_tokens: 0&lt;/code&gt;. A 3K-token prompt caches on some models and silently will not on others. Max 4 breakpoints per request.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What makes prompt caching miss on byte-identical requests?
&lt;/h2&gt;

&lt;p&gt;Two mechanisms, and both of them make you doubt your own diff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 20-position lookback.&lt;/strong&gt; Each breakpoint walks backward at most 20 positions looking for a prior entry. A run of consecutive &lt;code&gt;tool_use&lt;/code&gt; blocks counts as one position, and so does a run of consecutive &lt;code&gt;tool_result&lt;/code&gt; blocks, so heavy &lt;em&gt;parallel&lt;/em&gt; tool calling is fine. A long &lt;em&gt;sequential&lt;/em&gt; loop that appends more than 20 positions of other content in one turn is not: the next request's breakpoint never finds the previous entry, and you rewrite the whole conversation with a payload that diffs clean. Fix by placing an intermediate breakpoint every ~15 positions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parallel fan-out.&lt;/strong&gt; An entry becomes readable only once the first response &lt;em&gt;begins streaming&lt;/em&gt;. Fire 10 identical-prefix requests at once and all 10 pay full price, because none of them can read what the others are still writing. Send one, await the first streamed token, then fire the remaining nine. Same arithmetic applies to multi-agent designs: N workers each assembling a slightly different prompt over the same context write N entries and read none of each other's.&lt;/p&gt;

&lt;p&gt;And on TTL: the default 5-minute entry has its timer refreshed for free by every read, measured from the &lt;strong&gt;start&lt;/strong&gt; of the request. A four-minute generation leaves you about a minute for the next request to begin. The 1-hour TTL doubles the write cost, so it only pays off in the 5-to-60-minute gap, and needs at least three requests to break even.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why does cache_control write but never read?
&lt;/h2&gt;

&lt;p&gt;Because prompt caching is a prefix match, and a write-only cache means your breakpoint has volatile bytes in front of it. Something before the marker changes on every request (a &lt;code&gt;datetime.now()&lt;/code&gt; in the system prompt, an unsorted &lt;code&gt;json.dumps&lt;/code&gt;, a per-user tool list) or the marker itself sits after per-request content instead of at the end of the shared prefix. The fix is ordering, not more markers: put everything stable first, put the breakpoint at the last stable byte, put everything volatile after it, then verify with &lt;code&gt;usage.cache_read_input_tokens &amp;gt; 0&lt;/code&gt; on a second identical request. If that field stays at zero, no amount of &lt;code&gt;cache_control&lt;/code&gt; will save you, and you are paying 1.25× for the privilege.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>performance</category>
      <category>agents</category>
    </item>
    <item>
      <title>MCP Server stdout: One console.log Kills the Connection</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sat, 12 Sep 2026 04:47:52 +0000</pubDate>
      <link>https://dev.to/ji_ai/mcp-server-stdout-one-consolelog-kills-the-connection-506g</link>
      <guid>https://dev.to/ji_ai/mcp-server-stdout-one-consolelog-kills-the-connection-506g</guid>
      <description>&lt;p&gt;My MCP server connected fine. Tools showed up. I used it for about twelve minutes. Then, in the middle of a tool call, the client dropped the server and refused to bring it back.&lt;/p&gt;

&lt;p&gt;I had changed one line. I had added &lt;code&gt;console.log("fetching", url)&lt;/code&gt; inside a tool handler, because I wanted to see which URL it was hitting.&lt;/p&gt;

&lt;p&gt;That log line was the bug. Not a symptom of the bug. The bug. MCP server stdout is a reserved protocol channel, and I had just dumped a sentence of English into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An MCP server running over &lt;strong&gt;stdio&lt;/strong&gt; speaks newline-delimited JSON-RPC on &lt;strong&gt;stdout&lt;/strong&gt;. That stream belongs to the protocol, not to you.&lt;/li&gt;
&lt;li&gt;Any &lt;code&gt;console.log&lt;/code&gt;, &lt;code&gt;print()&lt;/code&gt;, banner, spinner, or &lt;code&gt;echo&lt;/code&gt; that lands on stdout injects a non-JSON line and the client's parser blows up. Connection dies.&lt;/li&gt;
&lt;li&gt;Logs at &lt;strong&gt;import time&lt;/strong&gt; break the handshake ("server failed to start"). Logs inside a &lt;strong&gt;tool handler&lt;/strong&gt; break the session twenty minutes in, which is why it feels random.&lt;/li&gt;
&lt;li&gt;Fix in Node: alias &lt;code&gt;console.log&lt;/code&gt; to &lt;code&gt;console.error&lt;/code&gt; at the top of your entry file. Fix in Python: &lt;code&gt;logging.basicConfig(stream=sys.stderr)&lt;/code&gt; and never call bare &lt;code&gt;print()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;stderr is free. Use it for everything. Or use the HTTP transport, where stdout is yours again.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does one console.log break an MCP server over stdio?
&lt;/h2&gt;

&lt;p&gt;Because stdio transport is not a terminal, it is a wire. The client spawns your server as a child process and uses the pipes directly: it writes JSON-RPC requests to your &lt;strong&gt;stdin&lt;/strong&gt; and reads JSON-RPC responses from your &lt;strong&gt;stdout&lt;/strong&gt;, one message per line.&lt;/p&gt;

&lt;p&gt;The contract is strict on purpose. Every line on stdout must be a valid JSON-RPC message, and a message must not contain an embedded newline. There is no framing header, no length prefix, no escape hatch. The newline &lt;em&gt;is&lt;/em&gt; the frame.&lt;/p&gt;

&lt;p&gt;So when your handler runs &lt;code&gt;console.log("fetching", url)&lt;/code&gt;, the stream your client is reading turns into this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"tools/list"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;fetching&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;https://api.example.com/v&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;/thing&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client reads line two, tries to parse it as JSON, and fails. Depending on the client, you get a hard parse error, a schema validation error, or a silent transport teardown. All three look like "the server died for no reason."&lt;/p&gt;

&lt;p&gt;The tell is beautiful once you know it: &lt;strong&gt;the error message contains your own log text.&lt;/strong&gt; If you see &lt;code&gt;Unexpected token 'f', "fetching h"... is not valid JSON&lt;/code&gt;, stop reading stack traces. You are looking at your own print statement being fed to a JSON parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did my MCP server work for twenty minutes and then fail?
&lt;/h2&gt;

&lt;p&gt;Timing. Where the stray write happens decides what the failure looks like, and this is the part that eats whole afternoons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write on import&lt;/strong&gt; and you corrupt the handshake. The server never initializes, the client marks it failed, and you get a clean, immediate, honest error. Annoying but findable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write inside a tool handler&lt;/strong&gt; and the server starts perfectly. Tools list fine. Everything is green. The stream only gets poisoned the first time that specific branch of that specific tool executes, which might be an hour into a session, and only when a particular argument is passed. That is the version that makes you suspect rate limits, the model, your network, anything except the debug line you added.&lt;/p&gt;

&lt;p&gt;Same root cause. Completely different-looking bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else writes to MCP server stdout without you noticing?
&lt;/h2&gt;

&lt;p&gt;Your own &lt;code&gt;console.log&lt;/code&gt; is the easy case. These are the ones people miss:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A dependency that prints on import.&lt;/strong&gt; Plenty of libraries announce themselves, warn about a GPU, or draw a progress bar. Straight to stdout, at import time, before your code runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Child processes with inherited stdio.&lt;/strong&gt; &lt;code&gt;spawn(cmd, args, { stdio: "inherit" })&lt;/code&gt; inside a tool handler wires the child's stdout directly into your protocol stream. Use &lt;code&gt;"pipe"&lt;/code&gt; and capture the output as a string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shell wrapper scripts.&lt;/strong&gt; If your server command is a &lt;code&gt;.sh&lt;/code&gt; file, every &lt;code&gt;echo&lt;/code&gt; in it is on stdout. So is the output of a version manager switching runtimes, and anything your shell profile prints when sourced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package-manager wrappers as the launch command.&lt;/strong&gt; Running the server through a script runner means lifecycle hooks, env-loader banners, and update notices all get a shot at stdout before your process exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spinners and progress bars.&lt;/strong&gt; They call &lt;code&gt;process.stdout.write&lt;/code&gt; directly, so patching &lt;code&gt;console&lt;/code&gt; alone will not save you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rule that covers all five: &lt;strong&gt;the command the client launches must produce nothing on stdout except MCP messages.&lt;/strong&gt; Not your file, the whole command.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I fix console.log in an MCP server?
&lt;/h2&gt;

&lt;p&gt;Node, first lines of your entry file, before any other import that might log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;log&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;debug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trace&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is safe because the stdio transport writes to &lt;code&gt;process.stdout&lt;/code&gt; directly rather than going through &lt;code&gt;console&lt;/code&gt;. You are only redirecting the human-facing helpers, not the wire.&lt;/p&gt;

&lt;p&gt;Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basicConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-mcp-server&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;logging&lt;/code&gt;'s default handler already writes to stderr, so the main job is refusing to use &lt;code&gt;print()&lt;/code&gt;. If you must, &lt;code&gt;print(msg, file=sys.stderr)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For a noisy third-party import, redirect at the file-descriptor level, which catches C extensions that bypass &lt;code&gt;sys.stdout&lt;/code&gt; entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;

&lt;span class="nd"&gt;@contextlib.contextmanager&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stdout_to_stderr&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dup2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dup2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;stdout_to_stderr&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;chatty_library&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if you actually want the client to see your logs, there is a supported path for it: MCP has a logging capability, and the server sends &lt;code&gt;notifications/message&lt;/code&gt; entries through the protocol. Those show up in the client instead of vanishing into stderr. That is the difference between debug output and telemetry.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I test that my MCP server's stdout is clean?
&lt;/h2&gt;

&lt;p&gt;Run the exact launch command and assert every stdout line parses as JSON. Sixty seconds, no client involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;your-launch-command 2&amp;gt;/dev/null | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; line&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"NOT JSON: &lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feed it a request on stdin to exercise startup, then call your tools through the MCP Inspector (&lt;code&gt;npx @modelcontextprotocol/inspector&lt;/code&gt;) with the same pipe in place to catch handler-time writes.&lt;/p&gt;

&lt;p&gt;Then make it impossible to regress. ESLint with &lt;code&gt;no-console&lt;/code&gt; set to allow only &lt;code&gt;error&lt;/code&gt; and &lt;code&gt;warn&lt;/code&gt; catches the common case, and a one-line CI grep for &lt;code&gt;console.log&lt;/code&gt; and bare &lt;code&gt;print(&lt;/code&gt; in your server source catches the rest. A stray debug line is a protocol violation in this codebase, so treat it like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I just use the HTTP transport instead?
&lt;/h2&gt;

&lt;p&gt;If stdout discipline keeps biting you, yes, and the trade-off is honest. With streamable HTTP, your server is a normal HTTP process: stdout is yours, logging is boring again, and you can run it once and attach several clients. The cost is that you now own a port, a lifecycle, and auth. stdio costs you nothing to run locally and demands one rule in return.&lt;/p&gt;

&lt;p&gt;Pick based on who runs the server. Local dev tool used by one person: stdio, with the console patch on line one. Anything shared or long-lived: HTTP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So why does one console.log kill an MCP server?&lt;/strong&gt; Because the stdio transport reserves stdout for newline-delimited JSON-RPC messages, and a log line is a line on that stream like any other. The client parses it, fails, and tears down the connection. Send every human-readable message to stderr instead, patch &lt;code&gt;console.log&lt;/code&gt; to &lt;code&gt;console.error&lt;/code&gt; at the top of your entry file, verify with a pipe that JSON-parses each stdout line, and the mystery disconnects stop.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>tooling</category>
      <category>tutorial</category>
      <category>cli</category>
    </item>
    <item>
      <title>I Ran claude -p in Cron 2,114 Times. 41 Failed Silently</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Fri, 11 Sep 2026 16:46:53 +0000</pubDate>
      <link>https://dev.to/ji_ai/i-ran-claude-p-in-cron-2114-times-41-failed-silently-4a9l</link>
      <guid>https://dev.to/ji_ai/i-ran-claude-p-in-cron-2114-times-41-failed-silently-4a9l</guid>
      <description>&lt;p&gt;For 90 days my laptop woke up at 6:40am, ran a headless Claude Code job, and mailed me a report. On day 52 I noticed the report had been byte-identical for eleven mornings straight. The job had been exiting &lt;code&gt;0&lt;/code&gt; the entire time and doing absolutely nothing.&lt;/p&gt;

&lt;p&gt;That is the thing nobody warns you about when you put &lt;code&gt;claude -p&lt;/code&gt; in cron: exit code 0 means the model finished talking. It does not mean the work happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Over 90 days and 4 scheduled jobs, I logged &lt;strong&gt;2,114 runs of &lt;code&gt;claude -p&lt;/code&gt;&lt;/strong&gt;. 41 of them (1.9%) exited 0 while producing no artifact.&lt;/li&gt;
&lt;li&gt;Every silent failure came from the same root cause: the headless session had &lt;strong&gt;fewer capabilities than my interactive session&lt;/strong&gt;, and the model narrated success instead of erroring out.&lt;/li&gt;
&lt;li&gt;The five modes: missing tools (14), broken &lt;code&gt;PATH&lt;/code&gt; (9), slash commands in the prompt (7), git lock collisions (6), and hangs on a permission prompt (5).&lt;/li&gt;
&lt;li&gt;The fix is not a better prompt. It is a ~40 line wrapper that &lt;strong&gt;checks the artifact's mtime, not the exit code&lt;/strong&gt;, plus a lock, a timeout, and a grep for apology phrases.&lt;/li&gt;
&lt;li&gt;After the guard: silent failures went from 41 to 0 in the following 30 days. Loud failures went from 0 to 19, which is the entire point.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does a silent failure in claude -p actually look like?
&lt;/h2&gt;

&lt;p&gt;It looks like a completely reasonable paragraph of English.&lt;/p&gt;

&lt;p&gt;Here is a real one, lightly trimmed, from the job that was supposed to post a draft through a browser extension:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I've prepared the draft and published it to the blog. The post is live with the title and tags as specified. Let me know if you'd like any adjustments.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nothing was live. Nothing was published. The headless session never had the browser tool in the first place, so the model did the most human thing possible: it wrote the success report it was asked to produce and stopped.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;echo $?&lt;/code&gt; said &lt;code&gt;0&lt;/code&gt;. My log line said &lt;code&gt;run ok&lt;/code&gt;. My mail said &lt;code&gt;[report] daily job — complete&lt;/code&gt;. Eleven times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does claude -p in cron fail silently?
&lt;/h2&gt;

&lt;p&gt;Because a cron environment strips away the things the model assumes it has, and a language model's default response to a missing capability is prose, not an exception. Here is the full breakdown of my 41 silent failures:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure mode&lt;/th&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;th&gt;What the log said&lt;/th&gt;
&lt;th&gt;What actually happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tool never existed headless&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;&lt;code&gt;run ok&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Browser/MCP tool absent, model described the action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PATH&lt;/code&gt; missing the binary&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;claude: command not found&lt;/code&gt;, swallowed by `\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slash command in the prompt&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;run ok&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Wrapper had slash commands disabled, model wrote an essay &lt;em&gt;about&lt;/em&gt; the command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git lock collision&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;run ok&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Two runs overlapped, &lt;code&gt;index.lock&lt;/code&gt; blocked the commit, model reported "committed"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hung on a permission prompt&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;timeout (unlogged)&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;--permission-mode&lt;/code&gt;, session waited forever for a human&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few of these deserve their own paragraph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools you have interactively, you do not have headlessly.&lt;/strong&gt; This was my single most expensive lesson. Any tool that comes from a browser extension, a desktop integration, or an MCP server you load interactively is simply not in the headless tool list. The model doesn't get a red error. It gets a smaller menu, and it improvises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;PATH&lt;/code&gt; thing is dumber than you think.&lt;/strong&gt; launchd (macOS) gives you a minimal &lt;code&gt;PATH&lt;/code&gt; that does not include &lt;code&gt;/opt/homebrew/bin&lt;/code&gt;. My job worked perfectly when I tested it in my shell and failed instantly under the scheduler. I had &lt;code&gt;|| true&lt;/code&gt; at the end of the line because I didn't want a red badge in my logs. Congratulations to me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slash commands are a client feature, not a model feature.&lt;/strong&gt; One of my prompts literally started with &lt;code&gt;/auto-publish&lt;/code&gt;. In my interactive session that expands into a whole skill. In my headless wrapper, slash commands were disabled, so the string went to the model as plain text and it thoughtfully explained what such a command would probably do. Seven mornings of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make a headless Claude Code cron job fail loudly?
&lt;/h2&gt;

&lt;p&gt;Stop trusting the process and start verifying the artifact. My entire fix is a wrapper script that every scheduled job now goes through. Four rules:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Check the output file, not the exit code.&lt;/strong&gt; Record the artifact's mtime before and after. If it didn't change, the run failed, no matter how cheerful the transcript was.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;before&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %m &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ARTIFACT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
run_claude
&lt;span class="nv"&gt;after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %m &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ARTIFACT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$after&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$before&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; fail &lt;span class="s2"&gt;"no artifact change"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Grep the output for surrender phrases.&lt;/strong&gt; Cheap, ugly, effective. These five patterns caught 100% of my narrated-success runs when I replayed the logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qiE&lt;/span&gt; &lt;span class="s2"&gt;"I (don't|do not) have (access|the ability)|I was unable to|I cannot directly|would need to be done manually|assuming (this|that) (is|was) successful"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; fail &lt;span class="s2"&gt;"model narrated instead of acting"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Lock and time out.&lt;/strong&gt; macOS has no &lt;code&gt;flock(1)&lt;/code&gt;, so I use a mkdir lock (atomic, no dependencies) and &lt;code&gt;gtimeout&lt;/code&gt; from coreutils.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;LOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/guard.&lt;/span&gt;&lt;span class="nv"&gt;$LABEL&lt;/span&gt;&lt;span class="s2"&gt;.lock"&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"skip: &lt;/span&gt;&lt;span class="nv"&gt;$LABEL&lt;/span&gt;&lt;span class="s2"&gt; still running"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'rmdir "$LOCK"'&lt;/span&gt; EXIT

gtimeout 900 /opt/homebrew/bin/claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permission-mode&lt;/span&gt; acceptEdits &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ERR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Absolute path to the binary. Explicit permission mode, because an unattended session that hits an approval prompt is a session that hits your timeout. A hard 15 minute ceiling, because my longest legitimate run was 6m12s and anything past that is a loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Parse the JSON, don't eyeball the text.&lt;/strong&gt; &lt;code&gt;--output-format json&lt;/code&gt; gives you a structured result with an error flag. Read it. It is the only signal in the whole pipeline that costs you nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'.is_error == false'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; fail &lt;span class="s2"&gt;"is_error true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Was 90 days of headless Claude Code worth it?
&lt;/h2&gt;

&lt;p&gt;Yes, and the numbers are not close. 2,114 runs across 4 jobs. 41 silent failures (1.9%), all of them concentrated in the first 60 days before the guard existed. In the 30 days after the guard, silent failures went to 0 and loud failures went to 19: mostly timeouts on days when a job genuinely had too much to chew, plus three lock skips from overlapping schedules.&lt;/p&gt;

&lt;p&gt;Average run: 74k input tokens, 3.1k output tokens, 2m48s wall clock. The most useful operational change I made had nothing to do with prompting. It was making the failure mail subject say what was &lt;em&gt;missing&lt;/em&gt; rather than that something broke. &lt;code&gt;job failed&lt;/code&gt; tells me nothing at 7am. &lt;code&gt;no post generated for today&lt;/code&gt; tells me exactly whether I need to care before coffee.&lt;/p&gt;

&lt;p&gt;The mental model that fixed everything: &lt;strong&gt;treat &lt;code&gt;claude -p&lt;/code&gt; like a flaky network call, not like a shell command.&lt;/strong&gt; A shell command that returns 0 did the thing. A network call that returns 200 might have returned a nicely formatted page that says "sorry, service unavailable." You check the body. Same here. Check the body.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why does claude -p in cron fail silently?
&lt;/h2&gt;

&lt;p&gt;Because a headless Claude Code session has strictly fewer tools than your interactive one, and when a capability is missing the model writes a plausible success report instead of raising an error, so the process still exits 0. In 2,114 runs I hit this 41 times, from five causes: absent tools, a minimal launchd &lt;code&gt;PATH&lt;/code&gt;, slash commands that don't expand headlessly, git lock collisions between overlapping runs, and hangs on permission prompts. The fix is not prompt engineering. Wrap the call in a guard that verifies the artifact's mtime changed, greps the output for surrender phrases, holds a lock, enforces a timeout, and parses &lt;code&gt;--output-format json&lt;/code&gt;. Verify the artifact, never the exit code.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>automation</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Cut Voice AI Latency From 4.2s to 780ms with Deepgram + ElevenLabs</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Fri, 11 Sep 2026 04:45:58 +0000</pubDate>
      <link>https://dev.to/ji_ai/i-cut-voice-ai-latency-from-42s-to-780ms-with-deepgram-elevenlabs-1i3n</link>
      <guid>https://dev.to/ji_ai/i-cut-voice-ai-latency-from-42s-to-780ms-with-deepgram-elevenlabs-1i3n</guid>
      <description>&lt;p&gt;The first version of my voice agent took 4.2 seconds to answer a question. Not "felt slow." Measured: 4,247ms median from the last syllable a human spoke to the first syllable that came back. Voice AI latency is the one metric where users don't need your dashboard to notice the regression. They just say "hello?" into the silence and then start repeating themselves, which produces a second transcript, which the bot also answers.&lt;/p&gt;

&lt;p&gt;I spent about three weeks taking that number apart. Here's the honest waterfall, what each fix actually bought in milliseconds, and the three things that broke when it got fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Median round-trip went from &lt;strong&gt;4,247ms to 780ms&lt;/strong&gt;. The LLM was never the main problem.&lt;/li&gt;
&lt;li&gt;The single biggest win was &lt;strong&gt;not waiting for the full completion&lt;/strong&gt;: stream the first sentence into text-to-speech. Worth ~1,430ms on its own.&lt;/li&gt;
&lt;li&gt;The second biggest was &lt;strong&gt;turn detection&lt;/strong&gt;, not inference. A fixed 1,200ms silence window was burning more time than the model did.&lt;/li&gt;
&lt;li&gt;Dropping the silence window to a flat 250ms made it fast and awful: it cut people off on &lt;strong&gt;31% of turns&lt;/strong&gt;. Adaptive endpointing got that to 6%.&lt;/li&gt;
&lt;li&gt;p95 is still &lt;strong&gt;1.6s&lt;/strong&gt;, almost entirely prompt-cache misses when someone thinks quietly for several minutes. The slowest answer goes to the person who paused the longest, which is exactly backwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What actually causes voice AI latency?
&lt;/h2&gt;

&lt;p&gt;Voice AI latency is a stack of six serial waits, and most of them are not the model. Here's my measured baseline, median over 40 recorded sessions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Baseline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Endpointing (fixed 1,200ms silence window)&lt;/td&gt;
&lt;td&gt;1,200ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deepgram final transcript after endpoint&lt;/td&gt;
&lt;td&gt;310ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM time-to-first-token (6.8k-token system prompt, uncached)&lt;/td&gt;
&lt;td&gt;1,050ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rest of the completion (structured JSON, ~180 tokens)&lt;/td&gt;
&lt;td&gt;1,240ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ElevenLabs full generation before playback&lt;/td&gt;
&lt;td&gt;380ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network + player buffer&lt;/td&gt;
&lt;td&gt;67ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,247ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at what that table says. Inference is 2,290ms of 4,247. The other 1,957ms is me waiting on purpose: waiting for silence to "confirm" the turn ended, waiting for a complete JSON object, waiting for a finished audio file. Every one of those waits was a default I never chose.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you measure voice agent latency without lying to yourself?
&lt;/h2&gt;

&lt;p&gt;Measure at the speaker, not at the server. My first instrumentation put a timestamp where the request hit my backend, and it reported numbers about 200ms better than reality. The browser's audio capture buffer and the upload leg are invisible from there, and they are latency the user feels.&lt;/p&gt;

&lt;p&gt;What I ended up with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Record the raw input track per session.&lt;/li&gt;
&lt;li&gt;Run offline voice-activity detection over that recording to find &lt;code&gt;t_last_speech&lt;/code&gt;, the true end of the human's last syllable.&lt;/li&gt;
&lt;li&gt;Timestamp first audible output frame on the client, not "response sent" on the server.&lt;/li&gt;
&lt;li&gt;Every stage event goes into one monotonic clock. Mixing &lt;code&gt;Date.now()&lt;/code&gt; across two machines gave me a stage that appeared to take negative time, which is how I found the bug.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The system all of this runs in is Preterview, a platform I built and operate that runs realistic voice interviews with three interviewer styles and returns a scored written report (full disclosure: I built it, &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;preterview.com/en&lt;/a&gt;). It's a useful testbed for latency work because the failure is so legible: a human is talking to it under pressure, and a pause that would be fine in a chat UI reads as the machine being broken. Most of the numbers in this post came out of that production traffic, not a synthetic loop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftd5zed80xfjpv7qjalsn.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftd5zed80xfjpv7qjalsn.jpg" alt="Preterview — an interview session in progress" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually cut the latency, ranked by milliseconds saved
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Stream TTS from the first sentence boundary (-1,430ms).&lt;/strong&gt; The original code did &lt;code&gt;await llm.complete()&lt;/code&gt; then &lt;code&gt;await tts.generate()&lt;/code&gt; then play. Nothing about that is necessary. Send the first complete sentence to ElevenLabs the moment the token stream produces one, keep feeding it, and the user hears audio while the model is still writing. This deleted the 1,240ms "rest of completion" wait and replaced a 380ms full generation with a ~90ms first chunk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Prompt caching on the system prompt and session context (-740ms).&lt;/strong&gt; My system prompt plus the running interview state is around 6.8k tokens and it is identical turn to turn. Uncached time-to-first-token was 1,050ms. Cached, 310ms. This is the cheapest win in the entire post: it's a cache-control marker on a prefix that never changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Adaptive endpointing (-920ms).&lt;/strong&gt; A fixed 1,200ms silence window assumes every pause means the same thing. It doesn't. If the transcript ends on a complete clause ("...so that's how I handled the migration"), I wait 220ms. If it ends on a filler or a hanging conjunction ("...and then, um"), I wait 900ms. Median across real turns: 280ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Plain text in the speaking path (-180ms).&lt;/strong&gt; The turn response was JSON, because the scoring pipeline wanted structured fields. But the scoring doesn't have to happen in the same call the human is waiting on. I split it: the spoken turn is plain text, and a second non-blocking call does the structured extraction after the audio is already playing. Constrained decoding also made time-to-first-token less predictable, and the tail matters more than the median when the user is staring at a microphone.&lt;/p&gt;

&lt;p&gt;Final measured budget, same methodology:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Adaptive endpoint decision&lt;/td&gt;
&lt;td&gt;280ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deepgram final transcript&lt;/td&gt;
&lt;td&gt;120ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM TTFT (cached prefix, plain text)&lt;/td&gt;
&lt;td&gt;180ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokens to first sentence boundary&lt;/td&gt;
&lt;td&gt;70ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ElevenLabs first audio chunk&lt;/td&gt;
&lt;td&gt;90ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network + player buffer&lt;/td&gt;
&lt;td&gt;40ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;780ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What broke when I made it fast?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Barge-in went from a bug to a feature request.&lt;/strong&gt; At a flat 250ms endpoint I clocked 418 turns by hand across 12 sessions. The bot started talking over the human on 31% of them. People pause mid-sentence to think, and a thinking pause and a finished pause look identical to a silence timer. Adaptive endpointing dropped it to 6%. Not zero. 6% of turns still get stepped on, and I have not found a clean fix that doesn't cost me back 400ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sentence splitting is not &lt;code&gt;split('.')&lt;/code&gt;.&lt;/strong&gt; My first chunker cut "3.5 years" into "3." and "5 years at a startup," and did the same to "Node.js." The audio came out with a hard stop in the middle of a number. The fix was boring: require period + space + capital letter, and enforce a 60-character minimum chunk before flushing to TTS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streaming ahead wastes audio you never play.&lt;/strong&gt; When a user interrupts, the TTS stream is already two sentences past what got heard. At one point 18% of generated speech characters were discarded audio nobody listened to. Capping the lookahead to two sentences pulled that down to about 7%. The fast version of the pipeline cost measurably more per session than the slow one, which is not the direction I expected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The p95 belongs to quiet people.&lt;/strong&gt; p95 is 1.6s, and the cause is almost entirely prompt-cache expiry. Cache entries have a time-to-live. A candidate who goes quiet for several minutes composing a thought comes back to a cold prefix and pays the full uncached TTFT. The person who most needed a fast, encouraging reply gets the slowest one. I now fire a cheap keepalive during long silences, which helps, and I'd rather report the ugly tail than a median that flatters me.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what's the real answer on voice AI latency?
&lt;/h2&gt;

&lt;p&gt;If your voice agent feels slow, the model is probably not your bottleneck. Instrument the pipeline end to end from the last human syllable to the first audible output frame, and you'll usually find that most of the wall clock is deliberate waiting: a fixed silence window before you'll admit the turn ended, a full completion before you'll start speaking, a full audio file before you'll start playing. Streaming text-to-speech from the first sentence boundary, prompt-caching a static prefix, and making the endpoint threshold adaptive to how the sentence ended took my own system from 4,247ms to 780ms median without changing the model. Then budget for the consequences: faster turn detection means more interruptions, and streaming ahead means paying for speech nobody hears.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
