<?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: Gabriel Le Roux</title>
    <description>The latest articles on DEV Community by Gabriel Le Roux (@gabrielleroux).</description>
    <link>https://dev.to/gabrielleroux</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%2F3999279%2Ff0ede00b-9597-4371-a6a5-b2268dbb9df0.jpg</url>
      <title>DEV Community: Gabriel Le Roux</title>
      <link>https://dev.to/gabrielleroux</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gabrielleroux"/>
    <language>en</language>
    <item>
      <title>Your Chars-Per-Token Estimate Is Wrong (Mine Was, By Exactly One Context Window)</title>
      <dc:creator>Gabriel Le Roux</dc:creator>
      <pubDate>Fri, 17 Jul 2026 07:07:00 +0000</pubDate>
      <link>https://dev.to/gabrielleroux/your-chars-per-token-estimate-is-wrong-mine-was-by-exactly-one-context-window-21jn</link>
      <guid>https://dev.to/gabrielleroux/your-chars-per-token-estimate-is-wrong-mine-was-by-exactly-one-context-window-21jn</guid>
      <description>&lt;p&gt;A user saved a long YouTube video. The pipeline pulled the transcript — about &lt;strong&gt;1 MB of text&lt;/strong&gt; — and sent it to the summarizer, which came back with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;400 Bad Request
This model's maximum context length is 128000 tokens,
however you requested 130700 tokens.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is annoying, because the truncation code that exists specifically to prevent this &lt;strong&gt;had run&lt;/strong&gt;. It did its math. It confidently produced a prompt ~2,700 tokens too big.&lt;/p&gt;

&lt;p&gt;Two bugs were hiding in that math. Both are the kind that live happily in production until an input arrives that's big enough to find them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 1: one setting doing two jobs it was never meant to do
&lt;/h2&gt;

&lt;p&gt;My AI capabilities — summarize, classify, tag, extract entities, translate — run through tenant-configurable connectors. Each connector has a &lt;code&gt;maxTokens&lt;/code&gt; setting.&lt;/p&gt;

&lt;p&gt;The handlers were using that one value for two completely different jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// what I had. both of these are wrong.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;maxOutput&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;connector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxTokens&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// sent to the model&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;maxInputChars&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;connector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxTokens&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// used to truncate input&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second line is a formula with the &lt;em&gt;structure&lt;/em&gt; of a guess. But the deeper problem is the first one, and it's conceptual.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A connector is shared.&lt;/strong&gt; Its &lt;code&gt;maxTokens&lt;/code&gt; might be set high because a chat-style consumer somewhere wants long completions. Feed that same number into a summarization call and you have:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reserved an enormous output budget the task will never use,&lt;/li&gt;
&lt;li&gt;Inflated your input allowance &lt;em&gt;from that number&lt;/em&gt;,&lt;/li&gt;
&lt;li&gt;Blown clean through the context window,&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;…for a task that was never going to emit more than a page of text.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: budget per capability, from the two numbers that are actually true
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AiTokenBudget&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// output is a property of the TASK, not of the connector&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;OutputCaps&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&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="s"&gt;"summarize"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;4_096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// nobody wants a 20k-token summary&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"classify"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;4_096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;4_096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"entities"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;4_096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"translate"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;16_384&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// output scales with input here&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ContextWindow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;128_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;CharsPerToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// see Bug 2&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;OutputTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;OutputCaps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4_096&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;MaxInputChars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;OutputTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// prompt overhead&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;inputTokens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ContextWindow&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;inputTokens&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;CharsPerToken&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;Output is capped by what the &lt;em&gt;task&lt;/em&gt; needs. Input gets whatever room is left in the real context window. The connector's &lt;code&gt;maxTokens&lt;/code&gt; goes back to being what it always should have been: a setting for consumers that need it, not a load-bearing constant for every capability in the system.&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%2Fyl7cvgwbmxtrei3alogj.png" 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%2Fyl7cvgwbmxtrei3alogj.png" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: real text is denser than prose
&lt;/h2&gt;

&lt;p&gt;The truncation estimate assumed &lt;strong&gt;3.0 characters per token&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;English prose runs around 4. So 3.0 &lt;em&gt;feels&lt;/em&gt; comfortably pessimistic. It feels like the safe choice a careful person makes.&lt;/p&gt;

&lt;p&gt;Then I actually measured the transcript that failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,048,576 chars / 360,335 tokens = 2.91 chars per token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2.91.&lt;/strong&gt; Below my "safe" floor.&lt;/p&gt;

&lt;p&gt;And of course it is. A timestamped YouTube transcript is not prose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;00:14:22.480 --&amp;gt; 00:14:25.120
- yeah, so the, um, the thing is —
00:14:25.120 --&amp;gt; 00:14:27.900
right, exactly, 40%, roughly
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Short lines. Numbers. Timestamps. Speaker fragments. Punctuation everywhere. Exactly the kind of text a BPE tokenizer shreds into tiny pieces. At 1 MB, the difference between "3.0, feels safe" and "2.91, measured" is thousands of tokens — which is to say, it was the &lt;em&gt;entire&lt;/em&gt; overflow.&lt;/p&gt;

&lt;p&gt;The constant is now 2.5, bought by measuring the input that actually failed rather than by picking a rounder number and feeling better about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When a heuristic fails, the fix is a datum, not a more confident-sounding guess.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The safety net: halve and retry
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable truth sitting underneath both fixes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Any&lt;/strong&gt; chars-per-token constant is wrong for &lt;em&gt;some&lt;/em&gt; input. Code samples. Dense CJK. Tables. Base64. Some transcript format I haven't met yet. One of them will eventually beat whatever ratio I pick, and I will not know in advance which.&lt;/p&gt;

&lt;p&gt;So the last line of defence doesn't estimate at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;SummarizeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AiContextOverflowException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// don't re-estimate. just take half.&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;half&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[..(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
    &lt;span class="n"&gt;_log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogWarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Context overflow at {Len} chars; retrying with half."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;SummarizeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;half&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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;Crude — and that's precisely the virtue. It makes &lt;strong&gt;no assumption about why the estimate lost&lt;/strong&gt;. It converges in a step or two. And it turns "job failed" into "summary of most of the transcript," which for summarization is an easy trade: a slightly shorter input is invisible in the output, while a hard failure is extremely visible in the app.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three layers, and you need all three
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Prevent the systematic error&lt;/strong&gt; — per-capability budgets, so one shared setting can't size every call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narrow the guess&lt;/strong&gt; — a ratio measured from inputs that actually failed, not one that sounded prudent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recover without the estimate&lt;/strong&gt; — halve-and-retry, which is right even when layers 1 and 2 are wrong.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Estimate conservatively. Verify against reality. Keep one recovery path that doesn't depend on your estimate being right.&lt;/p&gt;

&lt;p&gt;The 1 MB transcript summarizes fine now.&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaway you can steal
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't derive your input budget from your output setting. They're different numbers with different owners.&lt;/li&gt;
&lt;li&gt;Cap output by capability. A summarizer does not need 16k tokens of headroom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure your chars/token on the inputs that break you.&lt;/strong&gt; Your content type has its own density, and it is not prose.&lt;/li&gt;
&lt;li&gt;Keep a dumb recovery path. The clever one will eventually be wrong.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>dotnet</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Don't Build N Pipelines. Build N Adapters to One Pipeline.</title>
      <dc:creator>Gabriel Le Roux</dc:creator>
      <pubDate>Wed, 15 Jul 2026 07:06:00 +0000</pubDate>
      <link>https://dev.to/gabrielleroux/dont-build-n-pipelines-build-n-adapters-to-one-pipeline-fka</link>
      <guid>https://dev.to/gabrielleroux/dont-build-n-pipelines-build-n-adapters-to-one-pipeline-fka</guid>
      <description>&lt;p&gt;My read-it-later app had a quiet caste system.&lt;/p&gt;

&lt;p&gt;Save a &lt;strong&gt;web page&lt;/strong&gt; and you got the full treatment: clean reading view, AI summary, chunked, embedded, retrievable by the RAG chat.&lt;/p&gt;

&lt;p&gt;Save a &lt;strong&gt;PDF, a .docx, a screenshot, or a podcast episode&lt;/strong&gt; and you got... a stored file. A row in a table. A dead end.&lt;/p&gt;

&lt;p&gt;Last weekend I fixed that — PDFs, Office documents, images, audio and video all now flow into the reading view &lt;em&gt;and&lt;/em&gt; the RAG index. What I want to write up isn't the extraction code (it's mostly library calls). It's the &lt;strong&gt;one architectural decision&lt;/strong&gt; that made the whole thing cheap, and the &lt;strong&gt;one guard&lt;/strong&gt; that keeps the index honest.&lt;/p&gt;




&lt;h2&gt;
  
  
  The tempting design (don't do this)
&lt;/h2&gt;

&lt;p&gt;The obvious approach: a new pipeline per file type, each with its own result type, each with its own downstream handling.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the road to hell&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;PdfExtractionResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;PageCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;Fonts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;DocxExtractionResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;WordCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;Headings&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;AudioExtractionResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Transcript&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Lang&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ImageExtractionResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;OcrText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...and now 5 handlers, 5 mappers, 5 code paths into the indexer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five times the surface area. Five places to update when the chunker changes. Five subtly different ways for the same bug to manifest.&lt;/p&gt;




&lt;h2&gt;
  
  
  The design that shipped: one output contract
&lt;/h2&gt;

&lt;p&gt;Every extraction pipeline, whatever it does internally, produces &lt;strong&gt;the same shape as the existing page-content pipeline&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ExtractionResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;  &lt;span class="n"&gt;Markdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// the content, as markdown. always.&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt;     &lt;span class="n"&gt;WordCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Summary&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. PDF text extraction, DOCX paragraph walking, OCR, Whisper transcription — every one of them ends in &lt;em&gt;"here is this thing, as markdown."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The consequence is the whole point: &lt;strong&gt;the entire downstream flow needed zero changes.&lt;/strong&gt; Reading view, summarization, chunking, embedding, retrieval — none of it knows or cares what the file was. A podcast episode enters the vector index through &lt;em&gt;exactly&lt;/em&gt; the code path an article does.&lt;/p&gt;

&lt;p&gt;On the consuming side, the five per-type result handlers collapsed onto one base class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExtractionResultHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IHandler&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IExtractionMessage&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;CompleteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ExtractionResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetReadingArtifactAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Markdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WordCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Summary&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetSummaryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Markdown&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;_log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"No content extracted for {Id} — not embedding."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                              &lt;span class="c1"&gt;// ← the guard. see below.&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_bus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PublishAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;EmbedItemMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ct&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;New file type support is now, almost entirely, the question: &lt;strong&gt;what turns this into markdown?&lt;/strong&gt;&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%2Fzurslggk3rt4gnhn3ehz.png" 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%2Fzurslggk3rt4gnhn3ehz.png" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The four adapters
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;What it extracts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Documents&lt;/td&gt;
&lt;td&gt;OpenXml&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.docx&lt;/code&gt; paragraphs, &lt;code&gt;.xlsx&lt;/code&gt; sheets as pipe-separated rows, &lt;code&gt;.pptx&lt;/code&gt; slides &lt;strong&gt;including speaker notes&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images&lt;/td&gt;
&lt;td&gt;Tesseract&lt;/td&gt;
&lt;td&gt;OCR text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;Whisper&lt;/td&gt;
&lt;td&gt;Full transcript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video&lt;/td&gt;
&lt;td&gt;ffmpeg → Whisper&lt;/td&gt;
&lt;td&gt;Strip the audio track, hand it to the audio adapter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two notes worth more than the table:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PowerPoint speaker notes.&lt;/strong&gt; Most "read a .pptx" implementations give you a wall of bullet fragments. The notes are where the actual argument lives — the slide says &lt;em&gt;"Retention improved 18%"&lt;/em&gt;, the notes say &lt;em&gt;why&lt;/em&gt;, &lt;em&gt;which cohort&lt;/em&gt;, and &lt;em&gt;what we're not sure about&lt;/em&gt;. Extract them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;slidePart&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;presentationPart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SlideParts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ExtractSlideText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slidePart&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;slidePart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotesSlidePart&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;NotesSlide&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;InnerText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AppendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"&amp;gt; Speaker notes: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// markdown blockquote&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Video was nearly free.&lt;/strong&gt; Once audio worked, video is &lt;code&gt;ffmpeg -i in.mp4 -vn -acodec mp3 out.mp3&lt;/code&gt; and then... the audio adapter. That's the adapter pattern paying rent: the new type didn't need a new pipeline, it needed a converter to an existing one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The guard that keeps the index honest
&lt;/h2&gt;

&lt;p&gt;One check mattered more than any feature:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If extraction produces nothing, the item is not embedded.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the early &lt;code&gt;return&lt;/code&gt; in the handler above. Without it, a photo with no readable text enters the index on the strength of its &lt;em&gt;filename&lt;/em&gt;, and eventually the retriever hands &lt;code&gt;IMG_4211.jpg&lt;/code&gt; to the model as a "source" — at which point the model, being a model, improvises something plausible about a document it has never seen.&lt;/p&gt;

&lt;p&gt;That's the retrieval equivalent of confidently citing a blank page, and it is the single fastest way to destroy trust in a RAG feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An honest index has to be allowed to leave things out.&lt;/strong&gt; Three lines of code; the difference between a library and a liar.&lt;/p&gt;

&lt;p&gt;The same instinct applies one layer up: if the user asks a question about an item whose extraction hasn't finished, don't call the model at all. Return an honest "I haven't read this yet." The guard against hallucination belongs &lt;em&gt;before&lt;/em&gt; the model call, in code that checks whether an honest answer is even possible. No prompt engineering achieves what a precondition does.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two integration details that cost me an afternoon each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Extensionless PDF URLs.&lt;/strong&gt; My PDF detection checked for a &lt;code&gt;.pdf&lt;/code&gt; suffix. Plenty of PDF links have no extension — every &lt;code&gt;arxiv.org/pdf/2401.12345&lt;/code&gt; URL, for instance. Those saved &lt;em&gt;fine&lt;/em&gt; through the PDF path, but &lt;em&gt;also&lt;/em&gt; captured the browser's PDF-viewer DOM and queued a page-scrape job that could only ever fail. Fix: probe the content type; don't trust the URL's spelling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transcription needs its own connector.&lt;/strong&gt; Whisper wants an OpenAI-style &lt;code&gt;/audio/transcriptions&lt;/code&gt; endpoint. My default gateway for chat-style models (OpenRouter) doesn't serve one. "AI provider" is not one interface — transcription and chat are different sockets even when a single vendor offers both.&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't build N pipelines. Build N adapters to one pipeline.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The output shape is the contract. Everything upstream of it is replaceable, and everything downstream of it never has to know what happened.&lt;/p&gt;

&lt;p&gt;And whatever you build: let the index leave things out.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>dotnet</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Cloudflare 403s Your HttpClient. Here's the Four-Rung Ladder That Finally Got the PDF.</title>
      <dc:creator>Gabriel Le Roux</dc:creator>
      <pubDate>Tue, 14 Jul 2026 05:01:12 +0000</pubDate>
      <link>https://dev.to/gabrielleroux/cloudflare-403s-your-httpclient-heres-the-four-rung-ladder-that-finally-got-the-pdf-1b50</link>
      <guid>https://dev.to/gabrielleroux/cloudflare-403s-your-httpclient-heres-the-four-rung-ladder-that-finally-got-the-pdf-1b50</guid>
      <description>&lt;p&gt;A user saved a medRxiv paper to my app. PDF extraction failed — not a parse error, a &lt;strong&gt;403 Forbidden&lt;/strong&gt; on the download itself. The host sits behind Cloudflare, and Cloudflare had decided my server wasn't a person.&lt;/p&gt;

&lt;p&gt;This is the normal case now, not the exception. Preprint servers, industry reports, government documents — a growing share of the PDFs people actually want live behind bot detection. A "save this PDF" feature that only works on politely-served PDFs fails on all the interesting ones.&lt;/p&gt;

&lt;p&gt;What follows is the four-rung escalation ladder I ended up with. Each rung exists because the one below it failed on a real URL, and the whole thing is &lt;strong&gt;sorted by cost&lt;/strong&gt;, which turns out to be the most important design decision in it.&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%2Fs62num1b0en8n1785xjs.png" 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%2Fs62num1b0en8n1785xjs.png" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 1: HttpClient with realistic browser headers
&lt;/h2&gt;

&lt;p&gt;The baseline. A normal .NET &lt;code&gt;HttpClient&lt;/code&gt;, a real Chrome user-agent string, the full header set a browser would send.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HttpRequestMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpMethod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserAgent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ParseAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
    &lt;span class="s"&gt;"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ParseAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"application/pdf,text/html;q=0.9,*/*;q=0.8"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AcceptLanguage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ParseAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"en-US,en;q=0.9"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Sec-Fetch-Dest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"document"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Sec-Fetch-Mode"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"navigate"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This still works for most of the web and costs nothing. medRxiv 403'd it without blinking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it fails:&lt;/strong&gt; because modern bot detection doesn't read your headers. It reads your handshake.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rung 2: curl-impersonate, because TLS is the real fingerprint
&lt;/h2&gt;

&lt;p&gt;Here's the thing that took me embarrassingly long to internalise:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your TLS handshake identifies your HTTP client before a single byte of HTTP is sent.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every client negotiates TLS with a characteristic combination of cipher suites, extensions, elliptic curves, and — crucially — the &lt;em&gt;ordering&lt;/em&gt; of all of them. That combination is stable per library and is trivially fingerprintable (JA3/JA4). .NET's &lt;code&gt;SslStream&lt;/code&gt; announces "I am a program written in .NET" in the ClientHello. You can spoof every HTTP header you like; you're still holding up a sign.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/lwthiker/curl-impersonate" rel="noopener noreferrer"&gt;&lt;code&gt;curl-impersonate&lt;/code&gt;&lt;/a&gt; is curl rebuilt against the same TLS stack Chrome uses, with the handshake tuned to match byte for byte. You shell out to &lt;code&gt;curl_chrome116&lt;/code&gt; (or whichever build) and you look, at the transport layer, exactly like Chrome.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetTempFileName&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;psi&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ProcessStartInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"curl_chrome116"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;RedirectStandardError&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RedirectStandardOutput&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// important — see below&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="n"&gt;psi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArgumentList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"-sL"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;psi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArgumentList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"--max-time"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;psi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArgumentList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"60"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;psi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArgumentList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"-o"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="n"&gt;psi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArgumentList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;psi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArgumentList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;psi&lt;/span&gt;&lt;span class="p"&gt;)!;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitForExitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAllBytesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The implementation detail that bites everyone:&lt;/strong&gt; do not pipe a binary response through anything that touches strings. &lt;code&gt;RedirectStandardOutput = true&lt;/code&gt; plus &lt;code&gt;ReadToEndAsync()&lt;/code&gt; will hand you a &lt;code&gt;string&lt;/code&gt;, and the moment a PDF's bytes go through UTF-8 decoding they are silently, irreversibly corrupted. Write to a temp file, read it back as &lt;code&gt;byte[]&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rung 3: the same fingerprint, from a residential IP
&lt;/h2&gt;

&lt;p&gt;Some hosts don't care what you sound like, only where you're calling from. Whole datacenter ASNs are blocked outright — AWS, Hetzner, DigitalOcean, all of it.&lt;/p&gt;

&lt;p&gt;So rung 3 is rung 2 again, through a residential proxy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;psi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArgumentList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"--proxy"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;psi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArgumentList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"http://&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;proxyUser&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;proxyPass&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;proxyHost&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;proxyPort&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chrome's TLS fingerprint. A residential IP. A full browser header set.&lt;/p&gt;

&lt;p&gt;medRxiv 403'd that too.&lt;/p&gt;

&lt;p&gt;At which point the lesson lands: &lt;strong&gt;you are not going to out-imitate the browser. You have to be the browser.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Rung 4: an actual browser — and the indirection that makes it work
&lt;/h2&gt;

&lt;p&gt;The last rung drives a real stealth browser (I use Browserless; Playwright or Puppeteer with a stealth plugin gets you the same place).&lt;/p&gt;

&lt;p&gt;But you cannot just point a headless browser at the PDF URL and grab the response. The naive version fails exactly like the others, because a direct hit on the PDF has no session behind it. The move that matters is &lt;strong&gt;indirection&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Navigate to the &lt;em&gt;article page&lt;/em&gt; — the HTML landing page, not the PDF.&lt;/li&gt;
&lt;li&gt;Let the browser sit through and solve the Cloudflare challenge, the way any visitor does. It gets a clearance cookie.&lt;/li&gt;
&lt;li&gt;Then fetch the PDF &lt;strong&gt;from inside that page's JavaScript context&lt;/strong&gt;, so the request carries the cookies the challenge just minted.&lt;/li&gt;
&lt;li&gt;Return the bytes as base64, because the query layer between you and the browser only speaks scalars.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// evaluated inside the page, after the challenge has been solved&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pdfUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;include&lt;/span&gt;&lt;span class="dl"&gt;'&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;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the PDF request arrives as what it claims to be: a follow-on request from a browser that has already proved itself. That's the difference between &lt;em&gt;imitating&lt;/em&gt; trust and &lt;em&gt;acquiring&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;The medRxiv paper that had failed in production for three days came through at 189 KB with a clean &lt;code&gt;%PDF&lt;/code&gt; header.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the order matters more than the rungs
&lt;/h2&gt;

&lt;p&gt;The ladder isn't "try things until one works." It's sorted by &lt;strong&gt;cost&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rung&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Defeats&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;HttpClient + headers&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Naive UA checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;curl-impersonate&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;TLS fingerprinting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;curl_chrome + residential proxy&lt;/td&gt;
&lt;td&gt;Cheap&lt;/td&gt;
&lt;td&gt;Datacenter IP blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Stealth browser&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Metered&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full JS challenges&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rungs 1–3 are effectively free. Rung 4 is a paid, metered service with a per-session price. Escalating in cost order means the expensive tier only runs when every cheap option has genuinely failed — and it's quota-checked and usage-recorded like every other paid call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The expensive path being last is what makes it affordable to have at all.&lt;/strong&gt; Put the stealth browser first and you have a beautiful, reliable, financially ruinous fetcher.&lt;/p&gt;




&lt;h2&gt;
  
  
  The single most important line: validate the bytes, not the status code
&lt;/h2&gt;

&lt;p&gt;This is the part I'd tattoo on people if it were legal.&lt;/p&gt;

&lt;p&gt;Bot walls routinely return &lt;strong&gt;200 OK with an HTML block page&lt;/strong&gt; where the PDF should be. Trust the status code and that garbage flows downstream into your PDF parser, which fails later with a baffling error pointing nowhere near the actual cause. You will spend an afternoon debugging a "corrupt PDF" that was never a PDF.&lt;/p&gt;

&lt;p&gt;So every rung validates the payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;LooksLikePdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReadOnlySpan&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0x25&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0x50&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;   &lt;span class="c1"&gt;// %P&lt;/span&gt;
    &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0x44&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0x46&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// DF&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four bytes. A block page fails &lt;em&gt;immediately&lt;/em&gt;, at the rung that received it, and the attempt log reads like a story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1] plain      → 403
[2] curl_chrome → 403
[3] proxied     → 403
[4] stealth     → 200, %PDF, 189KB ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When some future host needs a rung five, that log is where I'll find out what to build.&lt;/p&gt;




&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;Cheap thing first. Escalate on failure. Validate the payload, not the envelope. Log every attempt.&lt;/p&gt;

&lt;p&gt;None of that is PDF-specific — it's the shape of any fetch pipeline that touches the adversarial web. The web you build against in development is cooperative. The web your users actually save from is not.&lt;/p&gt;

&lt;p&gt;Build the ladder before you need the top rung.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>webscraping</category>
      <category>cloudflarechallenge</category>
      <category>tls</category>
    </item>
    <item>
      <title>Turning a Pile of Saved Links Into a Queryable Knowledge Hub (.NET + pgvector)</title>
      <dc:creator>Gabriel Le Roux</dc:creator>
      <pubDate>Tue, 30 Jun 2026 19:21:00 +0000</pubDate>
      <link>https://dev.to/gabrielleroux/turning-a-pile-of-saved-links-into-a-queryable-knowledge-hub-net-pgvector-3ce</link>
      <guid>https://dev.to/gabrielleroux/turning-a-pile-of-saved-links-into-a-queryable-knowledge-hub-net-pgvector-3ce</guid>
      <description>&lt;p&gt;You save things for months — articles, videos, threads that explained exactly the thing you were stuck on. Your library fills up. And then, when you actually need something, you Google it from scratch anyway, because finding it in your &lt;em&gt;own&lt;/em&gt; saves feels harder than searching the whole internet again.&lt;/p&gt;

&lt;p&gt;That's the gap I wanted to close: turn a passive pile of bookmarks into a &lt;strong&gt;knowledge hub&lt;/strong&gt; you can just ask. Type a question in plain language, get a real answer, with links back to the exact saves it came from.&lt;/p&gt;

&lt;p&gt;Here's how it's built.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I'm the founder of SavePosty, a read-it-later app, and this is a write-up of a real feature in our own .NET codebase — not a sponsored post. Every detail below is from production.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;The hub is two pipelines that share one index. &lt;strong&gt;Ingest&lt;/strong&gt; runs once per save and builds the searchable representation. &lt;strong&gt;Query&lt;/strong&gt; runs every time you ask a question and reads that representation live.&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%2Fvw0p4t1ja50o5l50b6ld.PNG" 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%2Fvw0p4t1ja50o5l50b6ld.PNG" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing here is a separate search product. It's all .NET on the Postgres I already run — which turns out to be the most important design decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage: the hub lives in Postgres
&lt;/h2&gt;

&lt;p&gt;The popular instinct is to reach for a dedicated vector database. I used &lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;&lt;code&gt;pgvector&lt;/code&gt;&lt;/a&gt; instead — a Postgres extension that adds a &lt;code&gt;vector&lt;/code&gt; column type and distance operators (cosine, L2, inner product).&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%2Fc6svalqf4ho3eswmrfsf.PNG" 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%2Fc6svalqf4ho3eswmrfsf.PNG" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Why it's the right call &lt;em&gt;at my scale&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every save already lives in Postgres. A similarity search is &lt;strong&gt;one SQL query joined on &lt;code&gt;user_id&lt;/code&gt;&lt;/strong&gt; — no second datastore, no sync job, no "did the vector store drift from the relational store?" class of bug.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;HNSW&lt;/strong&gt; index turns that search from &lt;code&gt;O(n)&lt;/code&gt; brute force (seconds at half a million vectors) into &lt;code&gt;O(log n)&lt;/code&gt; graph traversal (milliseconds). It's approximate, but "close enough" is exactly what semantic search wants.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest tradeoff: the HNSW index is memory-hungry — it wants RAM as your vector count grows. That's fine per user; the real conversation starts at platform scale, and by then I'll be deciding with production numbers instead of a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embeddings: the provider is config, not code
&lt;/h2&gt;

&lt;p&gt;An embedding is just an array of floats that captures the &lt;em&gt;meaning&lt;/em&gt; of a chunk of text. The model that produces them is the one piece I deliberately did &lt;strong&gt;not&lt;/strong&gt; hardcode.&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%2Fqsgdlunokbv5aaue1v8r.PNG" 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%2Fqsgdlunokbv5aaue1v8r.PNG" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I started on self-hosted Ollama (&lt;code&gt;nomic-embed-text&lt;/code&gt;, zero per-call cost) and later moved to OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt;. The migration was &lt;em&gt;zero code&lt;/em&gt;: the embedding provider is a capability mapping in an admin UI, so I changed &lt;code&gt;Embedding → provider&lt;/code&gt; and re-indexed. If prices change or a better model ships, I flip it back the same way.&lt;/p&gt;

&lt;p&gt;The ingest details that matter: text is chunked into ~200-word windows with ~40-word overlap (so an idea that straddles a boundary is still retrievable), and an &lt;code&gt;EmbeddedAt&lt;/code&gt; timestamp gates visibility — the hub never answers from a half-indexed save.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent: it doesn't just retrieve, it acts
&lt;/h2&gt;

&lt;p&gt;A plain RAG chain is a fixed pipeline: embed query → retrieve top-k → stuff into prompt → answer. It works. But I wanted the hub to &lt;em&gt;do&lt;/em&gt; things, so I gave it an agent with tools.&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%2Frk4t2ag5rpml8qkfk0gh.png" 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%2Frk4t2ag5rpml8qkfk0gh.png" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The agent has &lt;code&gt;search_my_saves()&lt;/code&gt; and &lt;code&gt;create_collection()&lt;/code&gt;, and it decides when to use them. So it can skip searching when it already knows, reformulate a vague question, and — the fun part — &lt;strong&gt;act on the results&lt;/strong&gt;: ask it to "group everything I saved about RAG" and it searches, then builds a collection from what it found. Retrieval plus action, from one request.&lt;/p&gt;

&lt;p&gt;The cost is real (more tokens, slightly less determinism), so I split the work: a stronger model (&lt;code&gt;claude-sonnet-4-6&lt;/code&gt; via OpenRouter) drives the agent, while a cheap one (&lt;code&gt;claude-haiku-4-5&lt;/code&gt;) handles quick one-shot questions for next to nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming: the answer arrives as it forms
&lt;/h2&gt;

&lt;p&gt;Answers stream back over &lt;strong&gt;Server-Sent Events&lt;/strong&gt;, with typed events the UI reacts to.&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%2Ffh6v586z5zhj8f16w92m.PNG" 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%2Ffh6v586z5zhj8f16w92m.PNG" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;thinking&lt;/code&gt;, &lt;code&gt;text_chunk&lt;/code&gt;, &lt;code&gt;citation&lt;/code&gt;, &lt;code&gt;collection_created&lt;/code&gt; — that's the whole protocol. I chose SSE over WebSockets on purpose: the data flows one direction (server → client), so a stateful bidirectional socket would only add complexity. SSE is HTTP-native, reconnects cleanly, and the typed events are what make the live "thinking" indicator and inline citations possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd reach for next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid search&lt;/strong&gt; (BM25 + vector) to catch the exact-keyword queries pure semantic search occasionally fumbles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic chunking&lt;/strong&gt; instead of fixed windows, for tighter retrieval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedding-cache invalidation&lt;/strong&gt; so editing a save re-embeds only what changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The big lesson: a "knowledge hub" sounds like it needs heavy infrastructure, and it mostly doesn't. Postgres you already run, a swappable embedding provider, an agent with two tools, and a streaming endpoint get you a long way.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://saveposty.com" rel="noopener noreferrer"&gt;SavePosty&lt;/a&gt; in the open. If the internals are your thing, come follow the build — and tell me what you'd do differently in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>From Hangfire to RabbitMQ: Killing Database Polling in a .NET App</title>
      <dc:creator>Gabriel Le Roux</dc:creator>
      <pubDate>Tue, 23 Jun 2026 18:58:19 +0000</pubDate>
      <link>https://dev.to/gabrielleroux/from-hangfire-to-rabbitmq-killing-database-polling-in-a-net-app-4og4</link>
      <guid>https://dev.to/gabrielleroux/from-hangfire-to-rabbitmq-killing-database-polling-in-a-net-app-4og4</guid>
      <description>&lt;p&gt;Hangfire makes you look smart for almost no effort: drop in a NuGet package, point it at the database you already have, and you get a job runner &lt;em&gt;and&lt;/em&gt; a dashboard for free. For a single .NET service, it's genuinely hard to beat.&lt;/p&gt;

&lt;p&gt;So why did I pull it out of an entire app?&lt;/p&gt;

&lt;p&gt;Because I wasn't running one job. I was running &lt;strong&gt;all of them&lt;/strong&gt; through Hangfire — and every single one was hitting Postgres every few seconds asking &lt;em&gt;"anything for me yet?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the story of moving every polling Hangfire job in SavePosty — a read-it-later app I'm building — to RabbitMQ: the wins, the migration pattern that kept it boring, and the one thing you lose. The honest version.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I'm the founder of &lt;a href="https://saveposty.com" rel="noopener noreferrer"&gt;SavePosty&lt;/a&gt;. This is a write-up of a real migration in our own codebase, not a sponsored post — every example below is from production.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I was running before
&lt;/h2&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%2F3igbnz41pk5rdu8aphm4.PNG" 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%2F3igbnz41pk5rdu8aphm4.PNG" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Across the app, Hangfire ran &lt;strong&gt;all&lt;/strong&gt; my background work, backed by PostgreSQL — email sends, webhook dispatch, content re-fetch, and the rest. Different jobs, same mechanism: each one discovered work by &lt;strong&gt;polling the database on a timer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Credit where it's due, because the migration posts that pretend the old tool was garbage are lying to you. Hangfire gave me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero new infrastructure — it rides on the Postgres I already run.&lt;/li&gt;
&lt;li&gt;A dashboard out of the box: queued, processing, succeeded, failed.&lt;/li&gt;
&lt;li&gt;Retry logic in a single attribute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're one service with a small team, honestly, just use Hangfire. You'll be happy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I migrated
&lt;/h2&gt;

&lt;p&gt;I didn't leave because Hangfire was bad. I left because my situation changed.&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%2Fgb108e794nhpfkogfcor.PNG" 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%2Fgb108e794nhpfkogfcor.PNG" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Polling load that scales with the clock, not the work.&lt;/strong&gt; Every Hangfire job hits Postgres on an interval whether or not there's anything to do. One job is invisible. But I had many jobs, all polling the same database, all the time — constant read load that grows with your poll frequency and your job count, not with actual throughput. You pay it even when the app is idle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Two job mechanisms in one system.&lt;/strong&gt; One service, PostyFetch, already ran on RabbitMQ. Everything else still polled Hangfire. So I maintained two completely different models for "do work in the background" — two mental models, two runbooks, two places to look when something broke. Moving everything onto the broker collapsed that into one.&lt;/p&gt;

&lt;p&gt;A broker also &lt;em&gt;pushes&lt;/em&gt; work the moment it arrives instead of waiting for the next poll. For most jobs that latency win is small, but stacked on the two reasons above, the direction was obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest tradeoff
&lt;/h2&gt;

&lt;p&gt;No migration is free. If this table makes Hangfire look easy, that's because — for a single service — it is.&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%2Famas4c2ldtz1unl2t6gs.PNG" 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%2Famas4c2ldtz1unl2t6gs.PNG" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Hangfire&lt;/th&gt;
&lt;th&gt;RabbitMQ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;Drop-in NuGet&lt;/td&gt;
&lt;td&gt;A broker to deploy &amp;amp; manage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;SQL table&lt;/td&gt;
&lt;td&gt;Queues + dead-letter queues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;Management plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry config&lt;/td&gt;
&lt;td&gt;Attribute-level&lt;/td&gt;
&lt;td&gt;Consumer-level &lt;code&gt;MaxRetries&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev experience&lt;/td&gt;
&lt;td&gt;Easy local&lt;/td&gt;
&lt;td&gt;Needs Docker / a container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational cost&lt;/td&gt;
&lt;td&gt;Polling DB load (per job)&lt;/td&gt;
&lt;td&gt;Broker RAM + network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message requeue&lt;/td&gt;
&lt;td&gt;By job ID&lt;/td&gt;
&lt;td&gt;Queue-level (no per-message)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The case for RabbitMQ isn't "it's better." It's "it gets better with every extra service and every extra job that would otherwise be polling."&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that kept it boring: thin consumers
&lt;/h2&gt;

&lt;p&gt;This is the part that made a &lt;em&gt;multi-job&lt;/em&gt; migration safe instead of terrifying.&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%2F2lg1f24qjnpfk8t2dnpk.PNG" 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%2F2lg1f24qjnpfk8t2dnpk.PNG" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each RabbitMQ consumer does as little as possible: receive a message, deserialize it, and &lt;strong&gt;delegate to the existing job class&lt;/strong&gt;. No business logic moves. The code that sends an email or dispatches a webhook stays exactly where it was, still independently testable. The consumer is just a new front door in front of unchanged logic — and I used the identical shape for every job I migrated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The consumer is a thin shell. It owns transport, not behavior.&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendEmailConsumer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IConsumer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SendEmailMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;SendEmailJob&lt;/span&gt; &lt;span class="n"&gt;_job&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the SAME class Hangfire used&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;SendEmailConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SendEmailJob&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_job&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Consume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ConsumeContext&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SendEmailMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// No logic here. Just hand off.&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EmailId&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;Two things I got right by doing them first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retry parity.&lt;/strong&gt; The easy thing to get wrong, so I did it before anything else. Hangfire's &lt;code&gt;[AutomaticRetry(Attempts = 0)]&lt;/code&gt; became &lt;code&gt;MaxRetries = 0&lt;/code&gt; on the consumer. Get this wrong and you either hammer a failing downstream forever or silently drop messages that should have retried.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No breaking schema change.&lt;/strong&gt; I left the old &lt;code&gt;HangfireJobId&lt;/code&gt; column nullable. Old rows keep their IDs, new rows leave it null, and the data layer never needed a coordinated deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reliability: a dead-letter queue for every queue
&lt;/h2&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%2Fwr9ecmj6hyms8pb3mbyu.PNG" 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%2Fwr9ecmj6hyms8pb3mbyu.PNG" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every job got its own queue and its own DLQ — &lt;code&gt;postysend.sendemail.dlq&lt;/code&gt;, &lt;code&gt;postysend.webhookdispatch.dlq&lt;/code&gt;, and so on. When a message exhausts its retries, it lands in its DLQ instead of evaporating.&lt;/p&gt;

&lt;p&gt;There's a quiet upgrade hiding here too: because jobs now run as consumers, they can &lt;strong&gt;publish&lt;/strong&gt; messages themselves. I moved one job from &lt;code&gt;IBackgroundJobClient&lt;/code&gt; to &lt;code&gt;IMessagePublisher&lt;/code&gt;, so sending an email now publishes a &lt;code&gt;WebhookDispatch&lt;/code&gt; message directly. That kind of service-to-service chaining is awkward in Hangfire and completely natural on a broker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing you actually lose
&lt;/h2&gt;

&lt;p&gt;Here's the part most "we migrated and it's amazing" posts skip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You lose per-message failed-job inspection.&lt;/strong&gt; Hangfire's dashboard let me open a single failed job and read exactly what happened. RabbitMQ's management view gives me &lt;strong&gt;DLQ counts&lt;/strong&gt; — how many messages are dead-lettered, not the full story of each one without going and reading the queue directly.&lt;/p&gt;

&lt;p&gt;That's a real downgrade. If your debugging workflow leaned on opening one bad job at a time, plan for that gap &lt;em&gt;before&lt;/em&gt; you cut over, not after. I now reach for structured logs and DLQ replay instead of a pretty per-job screen, and that was an adjustment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you do this?
&lt;/h2&gt;

&lt;p&gt;Be honest with yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stay on Hangfire if:&lt;/strong&gt; you run a single service with a small team, you have no broker, you need the dashboard and per-job inspection, and DB polling load just isn't a problem at your scale. None of those are embarrassing reasons — they're good engineering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Move to RabbitMQ if:&lt;/strong&gt; you have multiple services, or enough jobs that polling the database is its own measurable cost; you already run (or can run) a broker; or you want pub/sub and fan-out, which Hangfire fundamentally can't do.&lt;/p&gt;

&lt;p&gt;The decision was never about a single job. It was about the &lt;em&gt;system&lt;/em&gt;. RabbitMQ wins when you're coordinating many moving parts; Hangfire wins when you're not.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://saveposty.com" rel="noopener noreferrer"&gt;SavePosty&lt;/a&gt; in the open — a faster, smarter home for everything you save. If the internals are your thing, come follow the build. Questions about the migration? Drop them in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>eventdriven</category>
      <category>ai</category>
      <category>postgres</category>
      <category>dotnet</category>
    </item>
  </channel>
</rss>
