<?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: backend</title>
    <description>The latest articles tagged 'backend' on DEV Community.</description>
    <link>https://dev.to/t/backend</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tag/backend"/>
    <language>en</language>
    <item>
      <title>Node.js Retrieval Architecture for Healthcare Appointment Citation Tracing</title>
      <dc:creator>CalderHayes9638</dc:creator>
      <pubDate>Fri, 06 Nov 2026 19:37:47 +0000</pubDate>
      <link>https://dev.to/calderhayes9638/nodejs-retrieval-architecture-for-healthcare-appointment-citation-tracing-42oc</link>
      <guid>https://dev.to/calderhayes9638/nodejs-retrieval-architecture-for-healthcare-appointment-citation-tracing-42oc</guid>
      <description>&lt;p&gt;Healthcare appointment assistants should use a staged retrieval design with explicit collections, bounded queries, and source context that can be traced back to a URL or document identifier. The answer is not “put every clinic document in one vector index.” A scheduler needs a small, reviewable evidence set: current appointment policy, location details, and the exact record that justified each sentence.&lt;/p&gt;

&lt;p&gt;Short answer: map each user-visible answer to a retrieval contract, retrieve a bounded candidate set, rerank it for relevance and freshness, and return citations with the context; keep writes and deletes deliberate so an outdated appointment policy cannot survive in the collection.&lt;/p&gt;

&lt;h2&gt;
  
  
  How can a healthcare appointment assistant make retrieval citations auditable?
&lt;/h2&gt;

&lt;p&gt;Start with the output, not the embedding model. For every answer field, define the evidence it is allowed to use. “Earliest available visit” might require a scheduling record with an observed timestamp; “bring a referral” might require a policy document and its effective date. The contract should carry &lt;code&gt;source_url&lt;/code&gt;, &lt;code&gt;document_id&lt;/code&gt;, &lt;code&gt;updated_at&lt;/code&gt;, and a collection name alongside the text chunk. A model can then explain uncertainty instead of blending two clinics’ rules.&lt;/p&gt;

&lt;p&gt;The contract also gives the reranker a boundary. Fetch, for example, a fixed top-k candidate set from the appointment-policy collection, then score lexical match, semantic relevance, and freshness in a separate stage. Do not let a broad web result silently outrank an internal policy. In a payment or ledger service I would insist on the same discipline: an audit trail is more useful than a clever score when someone asks why a result was shown.&lt;/p&gt;

&lt;p&gt;Keep the query bounded. Set a deadline for each source, cap retries, and return a partial answer with visible citations when a slow source misses its budget. This is an exactly-once mindset applied to reading: one request should produce one reproducible evidence bundle, even if a downstream call is retried.&lt;/p&gt;

&lt;p&gt;That boundary is where a gateway can help early.&lt;/p&gt;

&lt;p&gt;For a team prototyping this workflow, Infrai is a reasonable place to put the collection call: its REST surface means the same HTTP contract can be used from Go, Node.js, or a service written in another language, while one key covers adjacent backend capabilities. The choice still leaves citation metadata and clinical authorization in your application, where reviewers can inspect them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should chunking and freshness shape the appointment search?
&lt;/h2&gt;

&lt;p&gt;Chunk by the unit a reviewer can approve. A clinic’s cancellation rule, a same-day booking rule, and a holiday schedule should not share one giant chunk merely because they live in one PDF. Preserve headings and effective dates in metadata, and keep a stable &lt;code&gt;document_id&lt;/code&gt; when the source is re-indexed. Small chunks improve citation precision; overly small chunks lose the conditions that make a rule safe to apply.&lt;/p&gt;

&lt;p&gt;Freshness needs an explicit policy. On ingestion, upsert a changed document with a new content hash, then remove records for documents that were deleted upstream. A nightly job can catch drift, but an event-driven update is preferable for appointment availability. Retain the old identifier in an audit log rather than leaving its vector searchable. The cost is storage and review work; the benefit is that a clinician can reconstruct the evidence used at a particular time.&lt;/p&gt;

&lt;p&gt;Audit trails matter.&lt;/p&gt;

&lt;p&gt;Consider a policy PDF that changes at 09:00 while a patient is asking about a 10:30 visit. The ingestion worker should write the new hash, effective timestamp, and source URL before the retriever is allowed to use that version; a concurrent query can then either cite the previous version explicitly or wait for the new one, but it must not silently mix clauses from both. Store the retrieval request, candidate identifiers, reranker scores, and final citations with the assistant response. When a reviewer later sees “arrive 15 minutes early,” they can identify the exact page and revision, reproduce the bounded query, and determine whether the rule was current. This record is not busywork: it is the evidence that separates a correct correction from a mysterious change in model behavior. It also gives operations a clean place to measure stale-document hits without exposing patient data in debug logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing integration paths for citation tracing
&lt;/h2&gt;

&lt;p&gt;The practical choice is less about a leaderboard and more about how many moving parts your team must operate. Pinecone offers a hosted vector workflow with a focused SDK surface. Weaviate provides an open-source and hosted option with schema features. Elasticsearch is attractive when an existing search cluster already owns analyzers, filters, and operational dashboards. A single backend gateway can reduce credential and client sprawl, but it does not remove the need for a retrieval contract or a deletion policy.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Setup and credentials&lt;/th&gt;
&lt;th&gt;Citation and freshness work&lt;/th&gt;
&lt;th&gt;Good fit&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pinecone&lt;/td&gt;
&lt;td&gt;Hosted index and one vendor credential; SDK-led integration&lt;/td&gt;
&lt;td&gt;Metadata is available, while application code must enforce source tracing and re-index rules&lt;/td&gt;
&lt;td&gt;Teams wanting a focused managed vector service&lt;/td&gt;
&lt;td&gt;A separate search stack may still be needed for lexical constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weaviate&lt;/td&gt;
&lt;td&gt;Hosted or self-managed deployment with schema configuration&lt;/td&gt;
&lt;td&gt;Rich object metadata supports identifiers and timestamps&lt;/td&gt;
&lt;td&gt;Teams comfortable owning a schema-backed vector store&lt;/td&gt;
&lt;td&gt;More deployment choices mean more operational decisions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elasticsearch&lt;/td&gt;
&lt;td&gt;Often already present in the platform; familiar auth and query tooling&lt;/td&gt;
&lt;td&gt;Strong filters and text search help bound candidates; vector freshness remains your pipeline’s job&lt;/td&gt;
&lt;td&gt;Organizations standardizing on one search cluster&lt;/td&gt;
&lt;td&gt;Vector tuning can compete with existing cluster workloads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrai&lt;/td&gt;
&lt;td&gt;One REST API and one key can cover vector calls and adjacent backend capabilities, without installing an SDK&lt;/td&gt;
&lt;td&gt;The same application contract can call collection and record operations while retaining its own citation metadata&lt;/td&gt;
&lt;td&gt;Teams reducing integration friction across a mixed backend&lt;/td&gt;
&lt;td&gt;It is not suitable when you need Elasticsearch-specific analyzers or a deeply customized vector index; stick with Elasticsearch there&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reason to try Infrai here is contract stability: you can keep the application’s HTTP boundary while changing the service behind a capability, instead of rewriting every language client. Its broad, self-describing discovery surface also makes a first integration easier to inspect. That convenience is an integration advantage, not proof that its ranking is superior. Your mileage may vary when governance requires a specialist’s on-prem controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal collection lifecycle in Go
&lt;/h2&gt;

&lt;p&gt;The write path below creates an explicit collection. Production code should add an idempotency key to every mutating request, record the response request ID, and apply exponential backoff for 429 responses while honoring &lt;code&gt;Retry-After&lt;/code&gt;. The API key stays outside source control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"bytes"&lt;/span&gt;
    &lt;span class="s"&gt;"encoding/json"&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"io"&lt;/span&gt;
    &lt;span class="s"&gt;"net/http"&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&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;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"appointment-policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"metadata"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&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="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"purpose"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"citation-tracing"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MethodPost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"https://api.infrai.cc/v1/vector/collection/create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bearer "&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"INFRAI_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Idempotency-Key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"appointment-policy-2026-09-01"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&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;Timeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&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;Body&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadAll&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;Body&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;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusTooManyRequests&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rate limited; retry with exponential backoff and Retry-After"&lt;/span&gt;&lt;span class="p"&gt;)&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;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="o"&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;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"collection create failed: %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&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;data&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;The collection name is intentionally boring. That makes it easy to audit, and it leaves the medically meaningful fields in your own contract rather than hiding them in a vendor-specific schema. For deletion, use the documented vector delete operation after a source-of-truth deletion event; never treat a failed fetch as permission to erase records.&lt;/p&gt;

&lt;p&gt;The request is a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;https://api.infrai.cc/v1/vector/collection/create&lt;/code&gt; with &lt;code&gt;Authorization: Bearer $INFRAI_API_KEY&lt;/code&gt;; the Go sample makes those choices explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the design should stop
&lt;/h2&gt;

&lt;p&gt;This architecture is a poor fit for a team that needs millisecond-level, feature-rich lexical queries over a large existing corpus, or for a compliance program that mandates a particular self-hosted engine. Elasticsearch may be the better choice in those cases. It is also not a substitute for appointment-system authorization: retrieval can cite a policy, but the scheduling service must still check identity, consent, and slot ownership before confirming a visit.&lt;/p&gt;

&lt;p&gt;I am not sure any static chunk size will remain optimal as clinic policies evolve; measure citation misses and stale-document hits against a labeled review set, then adjust chunk boundaries deliberately. The important invariant is simpler: every answer has bounded evidence, every evidence item has a traceable origin, and every index change is auditable.&lt;/p&gt;

&lt;p&gt;Teams that want to validate the collection contract can start with the &lt;a href="https://docs.infrai.cc/en/guides/vector/answers/my-rag-chatbot-s-vector-search-keeps-letting-irrelevant/" rel="noopener noreferrer"&gt;vector retrieval guide&lt;/a&gt;, then compare its boundary with their own authorization and retention rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.infrai.cc" rel="noopener noreferrer"&gt;https://docs.infrai.cc&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://arxiv.org/abs/2005.11401" rel="noopener noreferrer"&gt;https://arxiv.org/abs/2005.11401&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.pinecone.io/" rel="noopener noreferrer"&gt;https://docs.pinecone.io/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://weaviate.io/developers/weaviate" rel="noopener noreferrer"&gt;https://weaviate.io/developers/weaviate&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html" rel="noopener noreferrer"&gt;https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rag</category>
      <category>healthcare</category>
      <category>retrieval</category>
      <category>backend</category>
    </item>
    <item>
      <title>AI Agents for Backend Engineers: What Carries Over and What's New</title>
      <dc:creator>Tech Geeks</dc:creator>
      <pubDate>Fri, 06 Nov 2026 19:02:45 +0000</pubDate>
      <link>https://dev.to/tech_geeks_a72ac908b0102d/ai-agents-for-backend-engineers-what-carries-over-and-whats-new-12jh</link>
      <guid>https://dev.to/tech_geeks_a72ac908b0102d/ai-agents-for-backend-engineers-what-carries-over-and-whats-new-12jh</guid>
      <description>&lt;p&gt;If you write backend code, you're closer to building production AI agents than you think. Queues, retries, idempotency, logs: it all carries over. What's new is a component that makes decisions, gives different answers to the same input, and can be talked into things. Here's the translation layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  An agent is an LLM with three upgrades
&lt;/h2&gt;

&lt;p&gt;A plain LLM call can only talk. An agent can act, because it adds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; functions it can call, such as querying a database or sending an email.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory:&lt;/strong&gt; a record of what has happened so far.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A loop:&lt;/strong&gt; it works step by step until the job is done. It thinks, acts, checks the result, and decides what's next. This pattern is called ReAct (reason + act).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before you build one, ask whether a single, well-prompted LLM call would do. Summarising a paragraph is one call. Finding, fixing and testing a bug across 40 files is a job for an agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools: the model asks, your code decides
&lt;/h2&gt;

&lt;p&gt;Function calling is the mechanism behind tools, and the most important thing to internalise is that &lt;strong&gt;the model never runs anything itself&lt;/strong&gt;. You give it a menu of tools, each with a name, description and parameters. When it wants one, it replies with a structured request, and your code decides whether to run it. That's what keeps you in control.&lt;/p&gt;

&lt;p&gt;Two habits make tools reliable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write descriptions like API docs&lt;/strong&gt;, including when &lt;em&gt;not&lt;/em&gt; to use the tool. "Fetch a customer's recent orders. Use for order status or refunds. Not for product search." That last sentence prevents a surprising number of wrong picks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate arguments like any user input.&lt;/strong&gt; Check the schema, check that referenced IDs exist, and rate-limit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;strong&gt;Model Context Protocol (MCP)&lt;/strong&gt;, released by Anthropic in late 2024, lets you wrap a tool once and reuse it across any MCP-compatible agent. Think of it as USB-C for AI tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory is plumbing you build
&lt;/h2&gt;

&lt;p&gt;LLMs remember nothing between calls; every bit of memory is something you store and feed back in. Short-term memory is the current conversation, limited by the context window. Long-term memory lives outside the model and is loaded only when relevant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAG&lt;/strong&gt; (retrieval-augmented generation) is the common way to bring in outside knowledge: store documents in a vector database, retrieve the closest chunks, and put them in the prompt. Retrieve broadly, then re-rank and keep only the best few, and tell the model to answer using &lt;em&gt;only&lt;/em&gt; that context so it doesn't guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkpointing&lt;/strong&gt; means saving the agent's state after every step, so a restart resumes instead of starting over. It's the same reason you keep sessions in a database, not an in-memory dict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure: normal backend work, with two twists
&lt;/h2&gt;

&lt;p&gt;Production agents are mostly the backend engineering you already do. The twists are that LLMs are non-deterministic and that their tool calls have real side effects. Four things keep you safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Durable execution.&lt;/strong&gt; Tools like Temporal save progress at every step, so a crash resumes where it left off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sensible timeouts and retries.&lt;/strong&gt; LLM calls can take a minute or more; back off politely on rate limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency keys.&lt;/strong&gt; A retried tool call must happen only once in the real world.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandboxes.&lt;/strong&gt; Run agent-written code in an isolated environment, never on your own server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idempotency key is the one engineers most often forget, and it's one line:&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;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refund:A123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# The payment service sees the same key twice but refunds only once.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cost matters too. Prompt caching lets providers reuse the repeated parts of your prompt; Anthropic reports savings of up to 90% on long prompts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability: agents fail quietly
&lt;/h2&gt;

&lt;p&gt;An agent's failures don't throw errors. You get a confident wrong answer, or a strange route to a right one, and a health check won't catch either. &lt;strong&gt;Tracing&lt;/strong&gt; records every step as a span: what the model decided, which tool it called, what came back, the cost and the latency. Tools like Langfuse and LangSmith show this as a timeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evaluation&lt;/strong&gt; is testing for agents: plain assertions for exact things (right tool, right ID), and an LLM judge scoring fuzzy things against a rubric. Run evals in CI before every release, sample live traffic afterwards, and turn every production failure into a new test case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security: assume the model can be tricked
&lt;/h2&gt;

&lt;p&gt;Agents act on text, and attackers can write text. The top risk is &lt;strong&gt;prompt injection&lt;/strong&gt;: instructions hidden in a page, email or document the agent reads. You can't fully prevent it, so contain it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Least privilege,&lt;/strong&gt; enforced in code, not in the prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human approval&lt;/strong&gt; for anything risky or irreversible: refunds, deletions, outgoing emails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat model output as untrusted.&lt;/strong&gt; Never &lt;code&gt;eval()&lt;/code&gt; it, and parameterise any SQL it touches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep secrets out of prompts.&lt;/strong&gt; The model gets tools, never raw API keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mantra: assume the model can be tricked, and make sure a tricked model can't do much damage. For example, an &lt;code&gt;issue_refund()&lt;/code&gt; that raises &lt;code&gt;NeedsHumanApproval&lt;/code&gt; above a set amount can't be pushed past that limit by a fooled model, because the code enforces the limit, not the prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go multi-agent last
&lt;/h2&gt;

&lt;p&gt;Multi-agent systems are expensive and fragile. Anthropic's multi-agent research system used about 15 times more tokens than a normal chat, and a 2025 study found failure rates of 41% to 87% across multi-agent frameworks. What works best is one lead agent holding the full picture, with small helpers that do one job and report back a summary.&lt;/p&gt;

&lt;h2&gt;
  
  
  A learning path for a few weekends
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Build a tiny agent with raw API calls and two tools, no framework.&lt;/li&gt;
&lt;li&gt;Add RAG over a folder of your own notes.&lt;/li&gt;
&lt;li&gt;Add tracing and 50 test cases, then break things on purpose.&lt;/li&gt;
&lt;li&gt;Rebuild it in a framework: LangGraph, or the OpenAI or Claude Agent SDK.&lt;/li&gt;
&lt;li&gt;Add durable execution and an approval step.&lt;/li&gt;
&lt;li&gt;Expose a tool through MCP.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By step six you'll understand what frameworks do under the hood, which makes you much better at choosing and debugging them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.techbrosebooks.co.in/blog/ai-agents-for-backend-engineers" rel="noopener noreferrer"&gt;TechBrosEbooks&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This article is adapted from the quick-start guide &lt;strong&gt;&lt;a href="https://www.techbrosebooks.co.in/books/ai-agent-orchestration-infrastructure-for-software-engineers?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ai-agents-for-backend-engineers" rel="noopener noreferrer"&gt;AI Agent Orchestration &amp;amp; Infrastructure for Software Engineers&lt;/a&gt;&lt;/strong&gt;, which covers each idea in one short chapter, with orchestration patterns, multi-agent handoffs and A2A, an end-to-end support-ticket triage agent, and a pre-ship checklist.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>backend</category>
      <category>llm</category>
    </item>
    <item>
      <title>Simple S3-Compatible Object Storage for Storing User-Uploaded Avatars (30-Day Deletion)</title>
      <dc:creator>FitzgeraldBlake3561</dc:creator>
      <pubDate>Fri, 06 Nov 2026 18:59:29 +0000</pubDate>
      <link>https://dev.to/fitzgeraldblake3561/simple-s3-compatible-object-storage-for-storing-user-uploaded-avatars-30-day-deletion-5fni</link>
      <guid>https://dev.to/fitzgeraldblake3561/simple-s3-compatible-object-storage-for-storing-user-uploaded-avatars-30-day-deletion-5fni</guid>
      <description>&lt;p&gt;For a media backend handling avatar uploads and signed documents, S3-compatible object storage is usually the simplest upload boundary. The difficult part is not putting bytes into a bucket. It is deciding which bytes survive the deadline, who can retrieve them, and what evidence remains after deletion.&lt;/p&gt;

&lt;p&gt;Short answer: keep the original signed document private, keep the avatar behind an application-controlled access path, and store the deletion deadline in your database. Use the bucket lifecycle as a backstop, not as the only erasure mechanism. A 30-day rule is easy to state; an exact deletion time, derived-file policy, and recovery decision are application responsibilities.&lt;/p&gt;

&lt;p&gt;The same rule applies to a user avatar, although the retention outcome differs. An avatar may live as long as the account needs it. A signed document may have a fixed end date. Do not let the shared upload code silently give both objects the same lifetime.&lt;/p&gt;

&lt;p&gt;For this workflow, Infrai is a plausible platform boundary: its private object storage can sit beside other backend capabilities behind one REST API and one key. That keeps the application contract focused on object keys and retention records while the service behind it can change. The deletion decision still stays in the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the 30-day retention bill actually keeping?
&lt;/h2&gt;

&lt;p&gt;Start with retained bytes, not upload calls. For each signed file, count the source document, preview images, extracted text, failed multipart fragments, and any audit material that the service deliberately stores. A 4 MB source with a 600 KB preview and 200 KB of extracted text is already a 4.8 MB retention decision before copies and abandoned uploads enter the picture.&lt;/p&gt;

&lt;p&gt;The dominant term changes when derivatives stop surviving for the full document window. Keep the signed original until its deadline. Delete a preview or extracted text earlier when the product does not need it. This is a useful trade: a later dispute may require regenerating a preview from the retained source, but it does not require retaining every convenient derivative forever.&lt;/p&gt;

&lt;p&gt;The retention ledger should contain the bucket, object key, received time, deletion deadline, object type, and deletion result. It belongs in a database because lifecycle rules have a one-day minimum granularity, while a business deadline may be precise to the second. A worker can read that ledger and retry a delete without treating “already gone” as a new failure.&lt;/p&gt;

&lt;p&gt;The timestamp is not the whole record. Suppose a signed contract arrives at 14:37:12 UTC and the media service creates a preview, a text extraction, and a moderation artifact. The ledger needs to connect those derived keys to the source and state which one may be deleted first. If the worker deletes the source before it records the result, the next retry must understand that the object is already absent. If a queue message is delivered twice, the second consumer must not create a new derivative or extend the deadline. If an operator changes the deadline, the audit record should explain who made the change and why. These are ordinary data-handling details, but they decide whether a deletion report is evidence or a guess assembled from bucket listings after the fact. The storage service can hold the bytes; it cannot infer the business relationship between an avatar, a signed original, and the temporary files produced while processing them.&lt;/p&gt;

&lt;p&gt;That boundary matters.&lt;/p&gt;

&lt;p&gt;What do I deliberately stop keeping? Usually temporary output and abandoned multipart pieces. The cost is operational: an engineer may need to regenerate a preview or investigate a missing intermediate artifact. That is a visible cost. Keeping undocumented copies is a hidden one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I use S3-compatible object storage for storing user-uploaded avatars?
&lt;/h2&gt;

&lt;p&gt;The comparison should focus on retention controls and processor boundaries, not on SDK preference. AWS S3, Cloudflare R2, and Backblaze B2 all expose S3-compatible access, but they are not interchangeable once the record needs a particular recovery or residency control.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Useful fit&lt;/th&gt;
&lt;th&gt;Boundary to verify&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS S3&lt;/td&gt;
&lt;td&gt;A specialist choice when mature lifecycle, versioning, Object Lock, and regional controls matter&lt;/td&gt;
&lt;td&gt;Versioned copies and retention policies can make deletion more involved than deleting one current key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2&lt;/td&gt;
&lt;td&gt;A reasonable choice when the delivery and operations boundary already sits with Cloudflare&lt;/td&gt;
&lt;td&gt;Confirm the regional and compliance model for signed records, rather than assuming edge reach means residency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backblaze B2&lt;/td&gt;
&lt;td&gt;Straightforward object storage with an S3-compatible API and lifecycle rules&lt;/td&gt;
&lt;td&gt;Check the required replication, region, and integration controls for the media service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrai storage&lt;/td&gt;
&lt;td&gt;A fit when private objects need to sit beside other backend capabilities behind one platform boundary&lt;/td&gt;
&lt;td&gt;No public-read ACL, versioning, Object Lock, If-Match conditional writes, cross-region automatic replication, or self-service CORS route&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Infrai is the interesting middle option for a team that wants the storage contract to stay portable while the surrounding backend changes. Its storage capability uses an S3-compatible interface, and the platform exposes storage alongside other backend capabilities through one REST API, one key, and one base URL. Swapping the service behind that boundary does not require changing the application’s retention ledger.&lt;/p&gt;

&lt;p&gt;The second advantage is less glamorous but practical during review: the discovery surface is public and self-describing, with request and response schemas, billing information, and runnable examples. Documented capabilities have examples in 10 languages. That reduces the friction of checking a storage-to-worker handoff across a Python service, a scheduled runtime, or a different team’s implementation; nobody has to infer the request contract from a client library. The platform has 295 routes across 20 modules, but breadth is not a retention guarantee.&lt;/p&gt;

&lt;p&gt;Here is the smallest server-side upload shape I would validate against the live schema. It assumes the bucket already exists, keeps the object private, reads the key from an environment variable, and treats rate limiting as a retryable 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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="kn"&gt;import&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;put_private_avatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;api_key&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="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFRAI_API_KEY&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;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="si"&gt;}&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-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;application/octet-stream&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;Idempotency-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;avatar:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&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="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&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;attempt&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;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&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;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PUT&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;https://api.infrai.cc/v1/storage/object/put/example-bucket/user-123-avatar&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="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;retry_after&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;headers&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;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_after&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;retry_after&lt;/span&gt; &lt;span class="k"&gt;else&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;attempt&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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&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;upload failed: &lt;/span&gt;&lt;span class="si"&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;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&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;text&lt;/span&gt;&lt;span class="si"&gt;}&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upload remained rate-limited after retries&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;This is a private object write, not a public image URL. For browser-direct transfer, use a presigned URL and keep the platform Authorization header away from that returned URL. The application should still validate the avatar type and associate the resulting key with its user record.&lt;/p&gt;

&lt;p&gt;This is where the recommendation needs a hard edge. The limitation is concrete: Infrai storage does not provide public URLs for these objects, so it is unsuitable for a permanent public image host or static-site asset bucket. It also does not provide object versioning or WORM-style Object Lock. An accidental overwrite is therefore not recoverable from storage history. Strict concurrent writes need coordination in a database or queue because there is no If-Match conditional write. Choose AWS S3 or another specialist when any of those controls is mandatory.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should the application enforce deletion rather than hope for it?
&lt;/h2&gt;

&lt;p&gt;Give the application ownership of the deadline. At upload time, create a private object key that does not encode a public identity, write the retention record in the same application transaction as the upload intent, and make retrieval go through an authorization decision. If direct transfer is needed, issue a presigned URL for the appropriate operation. The client must never send the platform Authorization header to that returned URL.&lt;/p&gt;

&lt;p&gt;The storage API can create a bucket and put an object through the verified storage routes, including &lt;code&gt;POST /v1/storage/bucket/create&lt;/code&gt; and &lt;code&gt;PUT /v1/storage/object/put/{bucket}/{key}&lt;/code&gt;. Those route names are enough to establish the boundary; the request schema should come from the live capability documentation rather than from a guessed payload in an article.&lt;/p&gt;

&lt;p&gt;The deletion worker then follows a small state machine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Select records whose deadline has passed and claim them with a lease.&lt;/li&gt;
&lt;li&gt;Delete the object by its stored bucket and key.&lt;/li&gt;
&lt;li&gt;Record the response and mark the ledger entry deleted when the object is gone.&lt;/li&gt;
&lt;li&gt;Retry transient failures with backoff, keeping the operation idempotent.&lt;/li&gt;
&lt;li&gt;Reconcile the ledger against object listings using prefix filtering, without pretending object metadata is server-searchable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Lifecycle remains useful as a second line of defense. It can catch a worker that was disabled, but it cannot replace the database when the shortest rule is one day and the product promise is an exact deletion deadline. Multipart fragments also have no automatic cleanup rule, so abandoned uploads need an explicit operational policy.&lt;/p&gt;

&lt;p&gt;The privacy boundary deserves equal attention. There is no browser-configurable standalone CORS route available for self-service direct uploads, even though the bucket model has a &lt;code&gt;cors_rules&lt;/code&gt; field. If a browser must upload directly, test the chosen integration path before making it the default. A server-mediated upload is slower to design but keeps the credential and authorization decision in a familiar place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does a specialist provider win?
&lt;/h2&gt;

&lt;p&gt;Use AWS S3 or another specialist when the record requires WORM protection, object history, cross-region automatic replication, or a contractual residency guarantee. Those are not details that a general backend surface should be assumed to supply. A runtime API does not create an audio or document residency commitment, and a deletion timestamp is not contractual proof that every processor copy disappeared.&lt;/p&gt;

&lt;p&gt;Cloudflare R2 is a better boundary when the organization already operates its delivery controls there and its regional requirements fit. Backblaze B2 deserves consideration when its lifecycle and region model match the service. Infrai is a reasonable choice for a media backend that wants private S3-compatible objects, one REST boundary for adjacent backend work, and a self-describing contract that is easy to inspect across languages. The application must still own the deadline, access policy, region decision, and deletion evidence.&lt;/p&gt;

&lt;p&gt;My recommendation is specific: try Infrai for the private upload and retention-ledger part of an avatar or signed-document workflow when portability and a single backend interface matter, and keep a specialist provider in the design when versioning, immutable retention, replication, or contractual residency is mandatory. That is the line between a convenient storage abstraction and a compliance control.&lt;/p&gt;

&lt;p&gt;The operational checklist is short. Classify originals and derivatives. Choose the region before accepting a file. Record an explicit deadline. Keep access private. Make deletion retries safe. Test that a presigned response does not become a permanent public URL. Record the result.&lt;/p&gt;

&lt;p&gt;If that boundary matches your system, start with the &lt;a href="https://docs.infrai.cc/en/guides/storage/answers/what-are-the-key-steps-for-a-react-app-to-upload-a-user/" rel="noopener noreferrer"&gt;storage capability documentation&lt;/a&gt; and verify the access, region, and deletion assumptions before adoption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.infrai.cc" rel="noopener noreferrer"&gt;Infrai official documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.infrai.cc/en/guides/storage/answers/what-are-the-key-steps-for-a-react-app-to-upload-a-user/" rel="noopener noreferrer"&gt;Infrai guide: React file uploads to object storage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition" rel="noopener noreferrer"&gt;MDN: Content-Disposition response header&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html" rel="noopener noreferrer"&gt;Amazon S3 Object Lock&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/r2/api/s3/api/" rel="noopener noreferrer"&gt;Cloudflare R2 S3 API compatibility&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.backblaze.com/apidocs/s3-compatible-api" rel="noopener noreferrer"&gt;Backblaze B2 S3-compatible API&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>objectstorage</category>
      <category>dataretention</category>
      <category>backend</category>
      <category>media</category>
    </item>
    <item>
      <title>O Fim do "Timeout" Silencioso: Padrões de Design para Filas de Alta Disponibilidade em Vendas</title>
      <dc:creator>Alejandro Cebrián Labrada</dc:creator>
      <pubDate>Fri, 06 Nov 2026 18:08:36 +0000</pubDate>
      <link>https://dev.to/alejandro_cebrinlabrada/o-fim-do-timeout-silencioso-padroes-de-design-para-filas-de-alta-disponibilidade-em-vendas-dbb</link>
      <guid>https://dev.to/alejandro_cebrinlabrada/o-fim-do-timeout-silencioso-padroes-de-design-para-filas-de-alta-disponibilidade-em-vendas-dbb</guid>
      <description>&lt;p&gt;Você termina de orquestrar um novo fluxo de captura de leads. Os webhooks estão mapeados, os payloads JSON batem perfeitamente com o esquema do banco de dados e o teste local retorna um status de sucesso em menos de 200 milissegundos. Você aprova o deploy para produção. Uma semana depois, a equipe de marketing escala a campanha, o tráfego atinge o pico esperado e o diretor de vendas avisa que contatos qualificados estão sumindo do pipeline. Ao investigar os logs do servidor, você encontra um cemitério de requisições derrubadas.&lt;br&gt;
Essa é a dura realidade de depender de integrações síncronas diretas em operações comerciais críticas.&lt;br&gt;
&lt;strong&gt;A Armadilha das Integrações Ponto a Ponto&lt;/strong&gt;&lt;br&gt;
Quando subimos instâncias auto-hospedadas de automação — como um motor n8n rodando em contêineres Docker em um VPS na DigitalOcean — a abordagem padrão de engenharia quase sempre é mapear a entrada de dados e apontar um nó HTTP diretamente para o CRM ou para o banco PostgreSQL da empresa. Operamos sob a premissa perigosa de que o servidor de destino estará sempre com baixa latência e pronto para receber a carga.&lt;br&gt;
No mundo real, CRMs corporativos impõem limites rigorosos de requisições simultâneas e bancos de dados sofrem picos de lentidão durante horários comerciais ativos. Se um webhook tenta empurrar um novo lead exatamente no milissegundo em que o servidor de destino está processando queries internas pesadas, a requisição HTTP trava. Em poucos segundos, a plataforma de origem corta a conexão por esgotamento de tempo.&lt;br&gt;
O resultado é um timeout silencioso. O sistema falha nos bastidores, nenhum alerta crítico é disparado para a equipe comercial e a oportunidade de negócio simplesmente evapora na rede sem deixar rastros.&lt;br&gt;
&lt;strong&gt;Desacoplando a Ingestão do Processamento&lt;/strong&gt;&lt;br&gt;
Depender de lógicas de retry configuradas na própria origem raramente resolve a raiz do problema. Bombardear um servidor que já está gargalado com requisições repetidas apenas agrava a latência da infraestrutura. Para proteger dados transacionais de alto valor, o paradigma arquitetônico precisa mudar: é obrigatório parar de tratar o roteamento de leads como uma chamada telefônica síncrona e começar a tratá-lo como uma fila de mensagens de alta disponibilidade.&lt;br&gt;
A solução estrutural exige o desacoplamento total. O momento de recebimento do webhook deve ser isolado do seu processamento final através de uma camada de buffer intermediária. A única função primária dessa infraestrutura é interceptar a requisição instantaneamente, devolver um 200 OK para a origem liberando a conexão de forma segura, e reter o payload. A partir desse momento, os dados são orquestrados e entregues ao destino final em um ritmo controlado, respeitando a capacidade real de absorção do servidor.&lt;br&gt;
&lt;strong&gt;O Middleware como Roteador Comercial&lt;/strong&gt;&lt;br&gt;
Construir, conteinerizar e manter esse barramento de eventos tolerante a falhas do zero consome um tempo precioso que as equipes de desenvolvimento poderiam aplicar no produto principal. É para resolver exatamente essa quebra de resiliência sem adicionar overhead de manutenção que plataformas de roteamento dedicado como o Zapflux são implementadas na arquitetura.&lt;br&gt;
Atuando como um middleware focado puramente no tráfego comercial, a ferramenta serve como o amortecedor vital entre os canais imprevisíveis de captura front-end e os bancos de dados rígidos de backend. Ela absorve o impacto de picos massivos de tráfego, gerencia a retenção das mensagens e garante a sincronização estrita dos dados apenas quando os sistemas de destino estão saudáveis e prontos.&lt;br&gt;
Engenharia de vendas escalável significa aceitar a premissa básica de que APIs externas inevitavelmente vão falhar, impor rate limits ou apresentar lentidão. Ao substituir conexões frágeis de ponta a ponta por uma camada de roteamento estruturada, eliminamos o risco da perda de pacotes e garantimos que a fricção técnica nunca seja a responsável por uma venda perdida.&lt;br&gt;
Como vocês costumam arquitetar a tolerância a falhas quando precisam integrar webhooks de alto volume com bancos de dados e CRMs legados em produção?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>systemdesign</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>Hangfire Alternative in .NET: Why I Replaced It With BackgroundService and Channel</title>
      <dc:creator>Kausar Raza</dc:creator>
      <pubDate>Fri, 06 Nov 2026 18:06:46 +0000</pubDate>
      <link>https://dev.to/kausarraza/hangfire-alternative-in-net-why-i-replaced-it-with-backgroundservice-and-channel-2kaj</link>
      <guid>https://dev.to/kausarraza/hangfire-alternative-in-net-why-i-replaced-it-with-backgroundservice-and-channel-2kaj</guid>
      <description>&lt;p&gt;The first version of this site was an ASP.NET Core MVC app with Hangfire wired in: a dashboard at &lt;code&gt;/hangfire&lt;/code&gt;, five recurring jobs, ten places in the code that called &lt;code&gt;BackgroundJob.Enqueue&lt;/code&gt;, and a &lt;strong&gt;second SQL Server database&lt;/strong&gt; just to hold Hangfire's job tables. When I rewrote the backend as a separate Web API, I didn't bring Hangfire with me. The API that serves this page runs two &lt;code&gt;BackgroundService&lt;/code&gt; classes fed by &lt;code&gt;System.Threading.Channels&lt;/code&gt;, and one &lt;code&gt;WHERE&lt;/code&gt; clause does the work of the every-minute job.&lt;/p&gt;

&lt;p&gt;This isn't a "Hangfire is bad" post. Hangfire is a good library. It's a post about checking which jobs actually needed it, because for this site the answer was none of them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The short version:&lt;/strong&gt; Use Hangfire (or Quartz.NET) when a job must survive a restart, retry on its own, or be visible on a dashboard. If the work is in-process, short-lived and fine to lose on a crash, a &lt;code&gt;BackgroundService&lt;/code&gt; with a bounded &lt;code&gt;Channel&lt;/code&gt; is built into .NET, needs no extra database, and is about 60 lines. A job that only "flips something on at a certain time" often needs no job at all: filter by time in the query.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Hangfire was doing on the old site
&lt;/h2&gt;

&lt;p&gt;The setup was the standard one from Hangfire's docs, with SQL Server storage on its own connection string:&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;// Program.cs of the old MVC site (connection string name only - the value lives in config)&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;hangfireConnectionString&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"HangfireConnection"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHangfire&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;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetDataCompatibilityLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CompatibilityLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Version_180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSimpleAssemblyNameTypeSerializer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseRecommendedSerializerSettings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSqlServerStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hangfireConnectionString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SqlServerStorageOptions&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;QueuePollInterval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;15&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;DisableGlobalLocks&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="p"&gt;}));&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHangfireServer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// ...and after app.Build():&lt;/span&gt;
&lt;span class="n"&gt;RecurringJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOrUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"check-scheduled-posts"&lt;/span&gt;&lt;span class="p"&gt;,&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;blogPostService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PublishScheduledPostsAsync&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;Here is every job it had, and what each one became in the new API:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hangfire job (old site)&lt;/th&gt;
&lt;th&gt;Schedule&lt;/th&gt;
&lt;th&gt;What replaced it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check-scheduled-posts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every minute&lt;/td&gt;
&lt;td&gt;Nothing runs. The query filters on &lt;code&gt;ScheduledAt &amp;lt;= now&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;daily-backup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;02:00 daily&lt;/td&gt;
&lt;td&gt;The hosting panel's scheduled backup, outside the app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;update-sitemap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Recurring&lt;/td&gt;
&lt;td&gt;A Next.js route handler that regenerates at most once an hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cleanup-expired-comments&lt;/code&gt;, &lt;code&gt;cleanup-old-logs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Recurring&lt;/td&gt;
&lt;td&gt;Dropped. The only cleanup left is inside a worker loop (below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fire-and-forget: welcome and reset emails, newsletter sends, image optimisation, analytics&lt;/td&gt;
&lt;td&gt;Enqueued per request&lt;/td&gt;
&lt;td&gt;Emails are sent inside the request. Page views go through a &lt;code&gt;Channel&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When I checked the API's database on 2 November, it had 31 tables and none of them belonged to Hangfire. The separate Hangfire database is only referenced by the old app's config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I didn't keep it
&lt;/h2&gt;

&lt;p&gt;Three reasons, in the order they mattered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The database is on remote shared hosting.&lt;/strong&gt; Every query this site makes crosses the internet to a shared SQL Server. Hangfire's SQL Server storage polls its queue table, and with &lt;code&gt;QueuePollInterval&lt;/code&gt; at 15 seconds that's 5,760 polls a day per worker, whether or not there's any work, plus the heartbeat and recurring-job checks. On a dedicated server nobody would notice. On a shared host with a round trip on every call, it's background load for nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Most of the jobs didn't need a scheduler.&lt;/strong&gt; The every-minute job looked for posts whose publish time had passed and flipped them live. But a post doesn't have to be &lt;em&gt;flipped&lt;/em&gt;. If every public query asks "is this post published and is its time in the past?", the post becomes visible at the right moment on its own:&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;// A post is live when it is published AND its scheduled time has passed.&lt;/span&gt;
&lt;span class="c1"&gt;// EF Core translates this to a WHERE clause, so "publishing on schedule" needs no job at all.&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;IsLive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;utcNow&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;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsPublished&lt;/span&gt;
         &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PublishedAt&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
         &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScheduledAt&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScheduledAt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;utcNow&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API's public post queries (lists, tags, authors, the single post) all go through that expression. There's no job to fail, no minute of lag, and nothing to monitor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second database is a second thing to back up, restore and secure.&lt;/strong&gt; When this site's main database was restored from a backup on 29 October, I had to work out which posts the restore had wiped. With Hangfire's state in another database, a restore of one without the other leaves jobs that point at rows that no longer exist. One database is simpler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The replacement: BackgroundService plus a bounded Channel
&lt;/h2&gt;

&lt;p&gt;The job that most needed to run in the background was the cheapest one: counting page views. Writing a view row on every post request meant the reader waited on a remote INSERT before the post came back. Now the endpoint drops an event on a channel and returns:&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;readonly&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;struct&lt;/span&gt; &lt;span class="nf"&gt;PostViewEvent&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;PostId&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;IpAddress&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;UserAgent&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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ViewTracker&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;Channel&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostViewEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_channel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateBounded&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostViewEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BoundedChannelOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Under a spike, lose a few analytics rows rather than block a request.&lt;/span&gt;
            &lt;span class="n"&gt;FullMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BoundedChannelFullMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DropWrite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;SingleReader&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="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ChannelReader&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostViewEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Reader&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Called from the GET /posts/{slug} endpoint. Never awaits, never touches the database.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PostViewEvent&lt;/span&gt; &lt;span class="n"&gt;view&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;_channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryWrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&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;A &lt;code&gt;BackgroundService&lt;/code&gt; reads the channel, waits up to 5 seconds or 200 events, and writes the batch in one go:&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;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ViewTrackerWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ViewTracker&lt;/span&gt; &lt;span class="n"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IServiceScopeFactory&lt;/span&gt; &lt;span class="n"&gt;scopeFactory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ViewTrackerWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BackgroundService&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;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;FlushInterval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&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;MaxBatch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;200&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;override&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;ExecuteAsync&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;stoppingToken&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;buffer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostViewEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;MaxBatch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCancellationRequested&lt;/span&gt;&lt;span class="p"&gt;)&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="c1"&gt;// Wait for the first event, then collect more for up to 5 seconds or 200 items.&lt;/span&gt;
                &lt;span class="k"&gt;if&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;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitToReadAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;break&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;window&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CancellationTokenSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateLinkedTokenSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CancelAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FlushInterval&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;do&lt;/span&gt;
                    &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MaxBatch&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryRead&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;view&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                            &lt;span class="n"&gt;buffer&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;view&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MaxBatch&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitToReadAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&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;OperationCanceledException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCancellationRequested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="c1"&gt;// The 5-second window ran out - the normal path.&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;

                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;FlushAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clear&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;OperationCanceledException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCancellationRequested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;break&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;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// One bad batch must not kill the loop - an unhandled exception here stops the&lt;/span&gt;
                &lt;span class="c1"&gt;// service, and since .NET 6 it stops the whole host by default.&lt;/span&gt;
                &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Failed to flush post views"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clear&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;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FlushInterval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stoppingToken&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="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&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;FlushAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PostViewEvent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;buffer&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="c1"&gt;// A BackgroundService is a singleton; DbContext is scoped. Make a scope per batch.&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;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scopeFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateScope&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;db&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServiceProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApplicationDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PostViews&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PostView&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;BlogPostId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PostId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ViewedAt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&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="c1"&gt;// One UPDATE per post, not one per view.&lt;/span&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="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GroupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PostId&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;n&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Count&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BlogPosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ViewCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ViewCount&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Registration is two lines. The tracker is a singleton so the endpoint and the worker share the same channel:&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;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ViewTracker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddHostedService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ViewTrackerWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second worker, for the site's real-user speed data, is the same pattern with a 20,000-event channel and 300-event batches. It also deletes rows older than 90 days, once a day, from inside the same loop. That one check replaced the two cleanup jobs I had in Hangfire.&lt;/p&gt;

&lt;h3&gt;
  
  
  Details that matter
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bounded, not unbounded.&lt;/strong&gt; &lt;code&gt;Channel.CreateUnbounded&lt;/code&gt; grows until the process runs out of memory if the database is down for long. A bound plus &lt;code&gt;DropWrite&lt;/code&gt; loses analytics rows instead, which is the right failure for a view counter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catch everything inside the loop.&lt;/strong&gt; Since .NET 6, an unhandled exception in &lt;code&gt;ExecuteAsync&lt;/code&gt; stops the host by default (&lt;code&gt;BackgroundServiceExceptionBehavior.StopHost&lt;/code&gt;). One failed batch shouldn't take the API down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a scope per batch.&lt;/strong&gt; Hosted services are singletons and &lt;code&gt;DbContext&lt;/code&gt; is scoped. Injecting the context directly into the worker either fails at startup or keeps one context alive forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch the UPDATE too.&lt;/strong&gt; &lt;code&gt;ExecuteUpdateAsync&lt;/code&gt; runs one &lt;code&gt;UPDATE ... SET ViewCount = ViewCount + n&lt;/code&gt; per post in the batch, not one per view.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I gave up
&lt;/h2&gt;

&lt;p&gt;This only works because I accepted these, on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anything in the channel is lost on a restart or crash.&lt;/strong&gt; Up to 5 seconds of page views. For a view counter that's fine. For a payment webhook or an order email it is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No retries.&lt;/strong&gt; Hangfire retries a failed job automatically (10 attempts by default). My worker logs the error and drops the batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No dashboard.&lt;/strong&gt; Failures show up in the log, not on a page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emails run inside the request.&lt;/strong&gt; A contact form submission waits for two SMTP sends before the visitor sees "sent". At this site's volume that's acceptable. With a slow mail server or real traffic, it wouldn't be, and that is the first thing I'd move back to a queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled posts aren't announced when they go live.&lt;/strong&gt; The API pings IndexNow when a post is saved, but only if it's already live. A post scheduled for tomorrow becomes visible tomorrow through the query filter, and nothing tells Bing. I submit those by hand. A scheduler would have handled that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IIS can stop the worker.&lt;/strong&gt; A &lt;code&gt;BackgroundService&lt;/code&gt; lives inside the app pool. If IIS idles the pool out, the worker stops until the next request. That's harmless for work that's created by requests, but a daily timer won't fire on a site nobody visited. Hangfire on IIS has exactly the same problem, which is why its docs tell you to configure the app to always run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Hangfire vs Quartz.NET vs BackgroundService
&lt;/h2&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;Quartz.NET&lt;/th&gt;
&lt;th&gt;BackgroundService + Channel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Survives a restart&lt;/td&gt;
&lt;td&gt;Yes (storage: SQL Server, Redis and others)&lt;/td&gt;
&lt;td&gt;Yes, with the ADO.NET job store&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automatic retries&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, you handle it in the job&lt;/td&gt;
&lt;td&gt;No, you write it&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;Not built in&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron schedules&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, the strongest of the three&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PeriodicTimer&lt;/code&gt; for fixed intervals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra storage&lt;/td&gt;
&lt;td&gt;Its own tables (or database)&lt;/td&gt;
&lt;td&gt;Its own tables, if persistent&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Free (LGPL), Pro is paid&lt;/td&gt;
&lt;td&gt;Free (Apache 2.0)&lt;/td&gt;
&lt;td&gt;Built into .NET&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you only need a recurring job and can live with it not running while the app is down, &lt;code&gt;PeriodicTimer&lt;/code&gt; covers it without a library:&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;// Generic example, not from this site: a daily job without Hangfire.&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NightlyCleanup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IServiceScopeFactory&lt;/span&gt; &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NightlyCleanup&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BackgroundService&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;override&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;ExecuteAsync&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;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;)&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;timer&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;PeriodicTimer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;24&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;do&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;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateScope&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;db&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServiceProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
                &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDays&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="m"&gt;90&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;At&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ExecuteDeleteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&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;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;OperationCanceledException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Nightly cleanup failed"&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="k"&gt;while&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;timer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitForNextTickAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&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;h2&gt;
  
  
  When I'd still pick Hangfire
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Work that &lt;strong&gt;must not be lost&lt;/strong&gt;: payments, order confirmations, anything a customer would notice was missing.&lt;/li&gt;
&lt;li&gt;Jobs that &lt;strong&gt;call flaky third-party APIs&lt;/strong&gt; and need retries with backoff.&lt;/li&gt;
&lt;li&gt;More than one server, where only one of them should run a job.&lt;/li&gt;
&lt;li&gt;A team that needs to &lt;strong&gt;see and re-run&lt;/strong&gt; failed jobs without reading logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of those apply, look at each job and ask whether it needs a scheduler at all, or just a query that checks the time. I wrote more about how the MVC site became a separate API and front end in &lt;a href="https://knowledgemarkg.com/blog/aspnet-mvc-to-react-migration" rel="noopener noreferrer"&gt;the migration post&lt;/a&gt;, and about keeping the API fast on remote hosting in &lt;a href="https://knowledgemarkg.com/blog/aspnet-core-web-api-performance" rel="noopener noreferrer"&gt;the Web API performance post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is a good Hangfire alternative in .NET Core?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For in-process work that can be lost on a restart, the built-in BackgroundService with a bounded System.Threading.Channels queue needs no library and no database. For persistent, cron-scheduled jobs, Quartz.NET is the usual free alternative. Hangfire is still the easiest choice when you need retries and a dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hangfire vs BackgroundService: which should I use?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use BackgroundService when the work is short, created by requests and fine to lose on a crash, such as view counters or log batching. Use Hangfire when a job must survive restarts, retry on failure or be visible on a dashboard, such as emails tied to orders or payments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hangfire vs Quartz.NET: what is the difference?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Hangfire is built around fire-and-forget and delayed jobs with automatic retries and a built-in dashboard. Quartz.NET is a scheduler first, with the most flexible cron and calendar triggers, but no built-in dashboard and no automatic retries. Both can persist jobs to a database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I make a background task queue in ASP.NET Core?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Register a singleton that wraps a bounded Channel, write to it from your endpoints with TryWrite, and read from it in a BackgroundService registered with AddHostedService. Create a DI scope per batch to get a DbContext, and catch exceptions inside the loop so one failure does not stop the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Hangfire need its own database?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Hangfire can store its tables in your application's database, under its own HangFire schema, or use a separate database or Redis. Either way it adds tables that have to be backed up and kept in step with your data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need a job to publish scheduled blog posts?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not necessarily. If every public query filters on the scheduled time, for example ScheduledAt &amp;lt;= now, the post appears at the right moment with no job. A job is only needed if something else must happen at that moment, such as notifying search engines or sending emails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will a BackgroundService keep running on IIS?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only while the application pool is running. IIS stops idle pools by default, which stops hosted services too. Work created by requests is fine, but timers need the app pool set to always running, the same requirement Hangfire has on IIS.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://knowledgemarkg.com/blog/hangfire-alternative-dotnet-backgroundservice-channel" rel="noopener noreferrer"&gt;knowledgemarkg.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>backend</category>
    </item>
    <item>
      <title>How fast is PostgreSQL LISTEN/NOTIFY? I ran some benchmarks to find out.</title>
      <dc:creator>Wojciech Wentland</dc:creator>
      <pubDate>Fri, 06 Nov 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/desty2k/how-fast-is-postgresql-listennotify-i-ran-some-benchmarks-to-find-out-5bod</link>
      <guid>https://dev.to/desty2k/how-fast-is-postgresql-listennotify-i-ran-some-benchmarks-to-find-out-5bod</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/desty2k/listennotify-performance-in-postgresql-3a82" class="crayons-story__hidden-navigation-link"&gt;LISTEN/NOTIFY performance in PostgreSQL&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/desty2k" class="crayons-avatar  crayons-avatar--l  "&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%2Fuser%2Fprofile_image%2F3866520%2F3ad33efb-4ece-434a-97e5-e9cecc7cc9fd.png" alt="desty2k profile" class="crayons-avatar__image" width="420" height="420"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/desty2k" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Wojciech Wentland
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Wojciech Wentland
                
                
              
              &lt;div id="story-author-preview-content-4983182" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/desty2k" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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%2Fuser%2Fprofile_image%2F3866520%2F3ad33efb-4ece-434a-97e5-e9cecc7cc9fd.png" class="crayons-avatar__image" alt="" width="420" height="420"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Wojciech Wentland&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/desty2k/listennotify-performance-in-postgresql-3a82" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Nov 5&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/desty2k/listennotify-performance-in-postgresql-3a82" id="article-link-4983182"&gt;
          LISTEN/NOTIFY performance in PostgreSQL
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/postgres"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;postgres&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/dotnet"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;dotnet&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/performance"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;performance&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/database"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;database&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
            &lt;a href="https://dev.to/desty2k/listennotify-performance-in-postgresql-3a82#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            9 min read
          &lt;/small&gt;
          
          &lt;div class="only-sidebar-menu-item trusted-visible-block print-hidden align-center"&gt;
                
                    
                
          &lt;/div&gt;

        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>backend</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Browser Direct Upload Storage Explained: 7 Presigned Upload Choices Without a Proxy Server</title>
      <dc:creator>VespasianBlack3884</dc:creator>
      <pubDate>Fri, 06 Nov 2026 17:57:15 +0000</pubDate>
      <link>https://dev.to/vespasianblack3884/browser-direct-upload-storage-explained-7-presigned-upload-choices-without-a-proxy-server-3ji5</link>
      <guid>https://dev.to/vespasianblack3884/browser-direct-upload-storage-explained-7-presigned-upload-choices-without-a-proxy-server-3ji5</guid>
      <description>&lt;p&gt;Short answer: for a customer-support app serving generated reports, use private object storage with server-issued presigned upload and download URLs. A unified storage API is a reasonable low-complexity choice when the app already needs several backend capabilities, but Cloudflare R2 or AWS S3 is a better fit when you need self-managed CORS, B2 compatibility, or public delivery.&lt;/p&gt;

&lt;p&gt;The browser should send the report bytes directly to storage. Your application server authenticates the customer, creates a short-lived signed URL, and records the object key. That keeps report data away from your proxy server and makes access control explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually drives the storage bill and retention risk?
&lt;/h2&gt;

&lt;p&gt;For this workflow, the dominant cost is usually retained bytes plus requests, not the tiny authorization call that creates a URL. A report that is generated once but downloaded by a support team ten times still occupies one object; retention policy decides whether that object disappears after 30 days or quietly becomes a permanent archive.&lt;/p&gt;

&lt;p&gt;My cost checklist has four rows: storage, upload requests, download requests, and egress. It is unglamorous, and it catches the bill faster than a vendor comparison chart.&lt;/p&gt;

&lt;p&gt;I start with a ledger: bytes stored, upload requests, download requests, and egress. Then I attach a retention class to each report. Temporary diagnostic exports can expire; legal or billing records need a separate archive policy. Lifecycle rules with a one-day minimum are useful, but they do not provide hour-level cleanup, and multipart fragments need an explicit cleanup process.&lt;/p&gt;

&lt;p&gt;The expensive surprise is often retention, not transfer. If you stop keeping generated reports after the support case closes, recovery becomes harder when a customer reopens a ticket. That is a real trade-off, so keep a database record of the report hash and generation inputs even after deleting the blob.&lt;/p&gt;

&lt;p&gt;One sentence version: delete less data, and you carry more compliance and recovery work.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should browser direct upload, presigned upload, CORS, and signed download fit together?
&lt;/h2&gt;

&lt;p&gt;The request path is simple: authenticate the customer, authorize a report key, ask storage for a presigned upload URL, and let the browser perform the upload. After the object exists, issue a separate signed download URL. Keep the bucket private by default; a permanent public link is an access-control decision, not a convenience setting.&lt;/p&gt;

&lt;p&gt;Here is a small Python client for a server-side URL issuer. It uses the documented presign and object-get routes, checks status codes, honors &lt;code&gt;Retry-After&lt;/code&gt; for rate limits, and sends an idempotency key so a retry cannot create two upload intents. A 429 is a queueing signal, not a reason to spin.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;BASE&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="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STORAGE_API_BASE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;KEY&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="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFRAI_API_KEY&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;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="si"&gt;}&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;post_with_backoff&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;payload&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;attempt&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;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&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="n"&gt;url&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="n"&gt;payload&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="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())},&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;delay&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;headers&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;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&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;delay&lt;/span&gt; &lt;span class="k"&gt;else&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;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate limit persisted after five attempts&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;create_upload_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_type&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;post_with_backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/storage/object/presign/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&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;method&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;PUT&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_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="p"&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;create_download_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/storage/object/get/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&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="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser then uses the returned upload URL with the method and headers specified by the response. Never put the API key in browser JavaScript. Validate the customer-to-key mapping before issuing either URL, and make the expiry shorter than the time a leaked link would be acceptable.&lt;/p&gt;

&lt;p&gt;Keep it private.&lt;/p&gt;

&lt;p&gt;In a support workflow, the object key should carry no customer-readable data: use a random identifier and store the ticket ID, MIME type, byte count, and checksum in your database. The upload request can be accepted before a report is marked ready, but the download issuer should require both an ownership row and a completed-generation state. That distinction matters when a browser tab is closed halfway through a multi-part upload or when a customer opens two tabs and submits the same report twice. A job can reconcile abandoned keys after the retention window, while the database remains the source of truth for who may read the file. The signed URL is only a temporary capability; it does not replace authorization, audit logging, malware scanning, or content-disposition policy. For downloaded reports, set a safe filename and attachment disposition so a browser does not render an unexpected HTML payload as a page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which storage option fits a European support workload?
&lt;/h2&gt;

&lt;p&gt;There is no universal winner. Region selection, CORS ownership, compatibility, and public delivery matter more than a single per-gigabyte number.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Strength for direct browser upload&lt;/th&gt;
&lt;th&gt;Important constraint&lt;/th&gt;
&lt;th&gt;Good fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS S3&lt;/td&gt;
&lt;td&gt;Mature presigned URLs, broad region and policy controls&lt;/td&gt;
&lt;td&gt;More configuration and a larger service surface&lt;/td&gt;
&lt;td&gt;Teams needing fine-grained IAM and EU region choice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2&lt;/td&gt;
&lt;td&gt;S3-compatible API and no egress charge to Cloudflare services&lt;/td&gt;
&lt;td&gt;CORS and account setup still belong to you&lt;/td&gt;
&lt;td&gt;Apps already close to Cloudflare's edge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bunny Storage&lt;/td&gt;
&lt;td&gt;Straightforward storage zones and CDN pairing&lt;/td&gt;
&lt;td&gt;Smaller ecosystem and fewer S3-era conventions&lt;/td&gt;
&lt;td&gt;Simple media delivery with Bunny's network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backblaze B2&lt;/td&gt;
&lt;td&gt;S3-compatible endpoint and attractive archival story&lt;/td&gt;
&lt;td&gt;B2-specific workflow and integration choices&lt;/td&gt;
&lt;td&gt;Cost-sensitive archives that accept vendor-specific operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A unified REST storage layer&lt;/td&gt;
&lt;td&gt;One contract across S3, R2, OSS, and COS-style backends; adding another backend capability is another endpoint&lt;/td&gt;
&lt;td&gt;No GCS or Backblaze B2 workflow, and CORS is not self-managed&lt;/td&gt;
&lt;td&gt;A backend that values one key and consistent calls across modules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For Europe, pin the bucket and the application region deliberately, then verify data-residency terms with your legal team. I am not sure a provider's advertised region alone answers every residency question; your audit trail, backups, and support tooling count too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the simple path stop being simple?
&lt;/h2&gt;

&lt;p&gt;The unified layer has clear boundaries. It does not expose public or &lt;code&gt;public-read&lt;/code&gt; ACLs, so &lt;code&gt;public_url&lt;/code&gt; remains null: static-site hosting, image-hosting links, and share-forever URLs are poor matches. It also lacks object versioning and WORM-style object lock, so an accidental overwrite is not recoverable from storage alone.&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;If-Match&lt;/code&gt; conditional write. For concurrent browser uploads, put the compare-and-swap decision in your database or job queue and make object keys unique per report revision. Metadata can be filtered by prefix during listing, but it is not a server-side search index. Cross-region replication and bulk cross-cloud migration are separate projects.&lt;/p&gt;

&lt;p&gt;Choose AWS S3 when those controls are contractual requirements. Choose Cloudflare R2 when Cloudflare delivery economics and S3 compatibility dominate. Choose Backblaze B2 when its archive workflow is the reason for the design. Choose Bunny Storage when a smaller, CDN-linked setup is more valuable than broad ecosystem support.&lt;/p&gt;

&lt;p&gt;Use signed upload and signed download URLs when reports are private, customer identity is known to your app server, and a short-lived capability is enough. The unified option earns its place when the same backend already needs other modules and you want one REST contract rather than a new SDK and credential set for every capability.&lt;/p&gt;

&lt;p&gt;Infrai is a reasonable choice in that narrow case with one REST API, plain HTTP, no SDK requirement, and one key, one bill for multiple backend capabilities on one platform, all using a consistent interface from any language. Adding a capability is another call instead of another integration. That breadth is the point; it is not a claim that every storage workload belongs there.&lt;/p&gt;

&lt;p&gt;Do not pick it for public assets, strict self-service CORS administration, B2 compatibility, or immutable retention. In those cases, stick with the provider whose native controls map directly to the requirement, even if that means another integration to maintain.&lt;/p&gt;

&lt;p&gt;The safest architecture is boring: private bucket, random object key, database ownership row, bounded URL lifetime, and an explicit deletion job. Boring is good when a support report contains customer data.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/s3/pricing/" rel="noopener noreferrer"&gt;https://aws.amazon.com/s3/pricing/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/r2/" rel="noopener noreferrer"&gt;https://developers.cloudflare.com/r2/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.backblaze.com/cloud-storage" rel="noopener noreferrer"&gt;https://www.backblaze.com/cloud-storage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bunny.net/storage" rel="noopener noreferrer"&gt;https://docs.bunny.net/storage&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>storage</category>
      <category>uploads</category>
      <category>backend</category>
      <category>security</category>
    </item>
    <item>
      <title>Receipt Messaging: Comparing Email and SMS API Providers Through Retention Costs</title>
      <dc:creator>GodfreySterling1574</dc:creator>
      <pubDate>Fri, 06 Nov 2026 17:37:43 +0000</pubDate>
      <link>https://dev.to/godfreysterling1574/receipt-messaging-comparing-email-and-sms-api-providers-through-retention-costs-4l0j</link>
      <guid>https://dev.to/godfreysterling1574/receipt-messaging-comparing-email-and-sms-api-providers-through-retention-costs-4l0j</guid>
      <description>&lt;p&gt;For a developer tools service sending an order receipt after payment settles, compare email and SMS APIs by the work required to preserve and reconcile evidence. The working budget should include message attempts, integration maintenance, retained notification records, and investigation time. A send request is only one part of that budget.&lt;/p&gt;

&lt;p&gt;TL;DR: Choose the integration whose acknowledgment, retry, and status semantics require the least application code to explain what happened to each settled order. Keep an application-owned receipt identity and a bounded audit trail. Evaluate email and SMS separately before deciding whether maintaining both channels earns its operational cost.&lt;/p&gt;

&lt;p&gt;Consider an explicitly hypothetical retention calculation: one receipt generates three attempt records, each occupying 2 KB in the application's chosen representation. Retaining those records for 30 days requires 180 KB-days per receipt, excluding indexes, replicas, and backups. Cutting that window to 10 days reduces the modeled footprint to 60 KB-days; preventing one unnecessary attempt reduces it to 120 KB-days at the original window. Neither number is a provider quote or a benchmark. In this model, the retention window is the larger multiplier, although actual billing and engineering effort require separate measurements.&lt;/p&gt;

&lt;p&gt;Count the evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the receipt bill actually buy?
&lt;/h2&gt;

&lt;p&gt;The useful comparison starts with a settled order, not a list of messaging features. Define the desired outcome as a receipt associated with the correct settlement, sent to the intended recipient through the chosen channel, with enough evidence to investigate an ambiguous result. Then account for the effort required to reach that outcome: implementing the integration, reviewing its security boundaries, maintaining templates, processing status events, handling uncertain attempts, and answering support questions after detailed records expire. For a backend team accustomed to reconciling ledgers, this is a familiar accounting problem, except that the external communication cannot be treated as an atomic ledger entry.&lt;/p&gt;

&lt;p&gt;Storage is measurable; investigation effort needs observation. Record both during the integration exercise rather than pretending that a short request example establishes total effort.&lt;/p&gt;

&lt;p&gt;The arithmetic above deliberately isolates retained attempt data. It does not establish that storage dominates a real deployment's costs. The engineering question is which term dominates &lt;em&gt;your&lt;/em&gt; proposed design, and whether the integration exposes enough information to measure it without retaining entire message bodies indefinitely. If initial adapter work dominates instead, shortening retention will barely change the decision. This distinction keeps price from becoming a substitute for understanding the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should teams compare email and SMS API providers for receipts?
&lt;/h2&gt;

&lt;p&gt;For this workload, “best provider” should mean the smallest justified integration burden under explicit receipt requirements. Evaluate candidates against the same acceptance exercise, and attach a documentation reference or a recorded test result to every completed comparison cell. An unknown remains an unknown; it should not quietly become an affirmative feature claim.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Contract to establish&lt;/th&gt;
&lt;th&gt;Email integration exercise&lt;/th&gt;
&lt;th&gt;SMS integration exercise&lt;/th&gt;
&lt;th&gt;Work to count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Meaning of acceptance&lt;/td&gt;
&lt;td&gt;Identify precisely what the send acknowledgment establishes&lt;/td&gt;
&lt;td&gt;Identify precisely what the send acknowledgment establishes&lt;/td&gt;
&lt;td&gt;Application states needed beyond acknowledgment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uncertain request outcome&lt;/td&gt;
&lt;td&gt;Determine the documented handling of a lost response&lt;/td&gt;
&lt;td&gt;Determine the documented handling of a lost response&lt;/td&gt;
&lt;td&gt;Duplicate prevention and investigation code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status evidence&lt;/td&gt;
&lt;td&gt;Map documented status events to internal receipt states&lt;/td&gt;
&lt;td&gt;Map documented status events to internal receipt states&lt;/td&gt;
&lt;td&gt;Authentication, deduplication, and reconciliation logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retention boundary&lt;/td&gt;
&lt;td&gt;Establish what evidence remains after local expiry&lt;/td&gt;
&lt;td&gt;Establish what evidence remains after local expiry&lt;/td&gt;
&lt;td&gt;Support procedures and retained sensitive data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a comparison method, not a claim that all APIs implement those contracts identically. For each candidate, inspect the scope and expiry of any documented idempotency mechanism, the meaning of its status fields, and the available reconciliation interface. A candidate that needs fewer translation rules may reduce integration effort, but a candidate with a longer evidence history may simplify investigations. Those benefits can conflict.&lt;/p&gt;

&lt;p&gt;Keep channel choice separate from failover policy. Sending an SMS merely because an email request timed out creates another external action before the first action's outcome is understood. Require a deliberate business rule for that transition, including what the recipient should see if both messages arrive. Automatic escalation otherwise turns uncertainty into additional retained evidence and potentially duplicated communication.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reduce duplicated evidence at the settlement boundary
&lt;/h2&gt;

&lt;p&gt;An exactly-once mindset begins with an application invariant: one logical receipt identity per settled order and receipt purpose. It does not require claiming exactly-once delivery to a human. For an illustrative developer tools purchase, an order such as &lt;code&gt;order_tools_42&lt;/code&gt; can have one logical receipt record and multiple separately identified attempts; a repeated settlement notification should resolve to that same logical receipt rather than manufacture another receipt identity.&lt;/p&gt;

&lt;p&gt;Design the settlement transition to persist a durable notification intent within the same transaction boundary where that is available. Where settlement evidence crosses systems, define a reconciliation procedure before promising atomic behavior. The receipt worker then processes the durable intent, records an attempt, and associates any external identifier with that attempt. A unique application identity protects the local invariant; whether it also prevents a repeated external send depends on the external API's documented contract.&lt;/p&gt;

&lt;p&gt;The dangerous gap is small: the send may have succeeded while the worker failed to record its response. A local uniqueness constraint cannot establish what happened outside the database. Mark that attempt as uncertain, preserve its correlation evidence, and follow the integration's documented recovery path. Retrying blindly would make the audit trail longer without making the original outcome clearer.&lt;/p&gt;

&lt;p&gt;Keep uncertainty explicit.&lt;/p&gt;

&lt;p&gt;The following Go example makes the retention model executable. Its inputs are the hypothetical assumptions from the opening; the calculation excludes infrastructure overhead and says nothing about actual provider charges.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"fmt"&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;retainedKBdays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kbPerAttempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int64&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;attempts&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;kbPerAttempt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retainedKBdays&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="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c"&gt;// 180 KB-days&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retainedKBdays&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="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c"&gt;// 60 KB-days&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retainedKBdays&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="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c"&gt;// 120 KB-days&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the receipt identity, settlement reference, attempt identity, template version, timestamps, external correlation identifier when available, and status transitions needed for reconciliation. Treat recipient addresses and message contents as separate retention decisions. Keeping a template version helps explain which rendering rules applied, but it cannot reconstruct a historical message if the input data has changed or expired. That loss matters when choosing how much evidence to preserve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the integration prove its maintenance cost
&lt;/h2&gt;

&lt;p&gt;Before deployment, exercise duplicate settlement input, a lost send response, repeated status events, and a late status event after local detail expires. These are proposed test cases, not reported incidents. For each case, inspect the resulting receipt record and ask whether a support engineer can explain the outcome without consulting unstructured logs or sending the message again to see what happens.&lt;/p&gt;

&lt;p&gt;Make the release small enough to reconcile manually. Observe outstanding intents, uncertain attempts, duplicate events suppressed, and receipts awaiting reconciliation; define what each counter measures so that a healthy request-success rate cannot conceal an accumulating queue. Include a rollback procedure for the worker and template release, with a clear rule for intents already claimed. The integration-effort comparison should count these deployment and maintenance obligations alongside the initial adapter implementation.&lt;/p&gt;

&lt;p&gt;There is also a compliance boundary. RFC 8058 specifies a one-click unsubscribe mechanism for list email, including the &lt;code&gt;List-Unsubscribe&lt;/code&gt; and &lt;code&gt;List-Unsubscribe-Post&lt;/code&gt; fields and associated authentication requirements. It does not establish the legal classification of a particular receipt, provide SMS consent rules, or set a universal retention period. Keep transactional receipt content and any proposed promotional content distinguishable, and obtain the applicable policy decision before treating one channel's unsubscribe behavior as sufficient for another channel or message purpose.&lt;/p&gt;

&lt;p&gt;An audit trail supports an investigation. It does not certify compliance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide what expires before selecting the API
&lt;/h2&gt;

&lt;p&gt;Set separate retention rules for message bodies, recipient details, attempt records, and the underlying financial records. Tie each rule to an operational need and the applicable requirements; the illustrative 10-day and 30-day windows above are analytical assumptions, not retention recommendations. Include indexes, backups, exports, and copies in the deletion design, or the apparent reduction in the primary record store will misrepresent what remains.&lt;/p&gt;

&lt;p&gt;The trade-off is explicit: compact metadata reduces retained detail but limits historical reconstruction. This design is not suitable for a workflow that must reproduce the exact receipt after its body and inputs expire; that workflow needs a separately governed content archive. The limitation is evidentiary, and adding another channel does not remove it.&lt;/p&gt;

&lt;p&gt;For a receipt service, a defensible decision rule is to prefer the candidate that satisfies the required channel and evidence contracts with the fewest maintained states, recovery paths, and sensitive-data copies. Document any deliberate exception, such as accepting more adapter work to obtain evidence needed for a specific support obligation. No ranking survives a change in those obligations.&lt;/p&gt;

&lt;p&gt;The final retention decision should name the evidence the team will stop keeping. If rendered bodies and recipient snapshots expire while compact attempt metadata remains, support can still trace the logical receipt and its recorded transitions, but may lose the ability to reproduce its exact wording and destination at send time. That is the investigation cost of the smaller archive. Accept it explicitly, and keep any legally required financial evidence under its own retention policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;p&gt;References:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RFC 8058, &lt;em&gt;Signaling One-Click Functionality for List Email Headers&lt;/em&gt;: &lt;a href="https://datatracker.ietf.org/doc/html/rfc8058" rel="noopener noreferrer"&gt;https://datatracker.ietf.org/doc/html/rfc8058&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>backend</category>
      <category>email</category>
      <category>api</category>
    </item>
    <item>
      <title>Email and Phone Normalization: Common Mistakes</title>
      <dc:creator>chirag</dc:creator>
      <pubDate>Fri, 06 Nov 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/chirag191094/email-and-phone-normalization-common-mistakes-dif</link>
      <guid>https://dev.to/chirag191094/email-and-phone-normalization-common-mistakes-dif</guid>
      <description>&lt;p&gt;A contact lookup finds no match because one phone number includes spaces and another does not. The obvious response is to clean every value aggressively. That can introduce a different problem: changing a valid identifier into the wrong one.&lt;/p&gt;

&lt;p&gt;Normalization should produce a consistent representation while retaining the original input when there is a justified need to inspect it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate normalization from validation
&lt;/h2&gt;

&lt;p&gt;Normalization changes representation. Validation checks whether a value meets an expected structure. Neither proves that a person owns an email address or phone number.&lt;/p&gt;

&lt;p&gt;A syntactically valid address can still be unreachable. A well-formatted phone number can belong to a different person. Treat ownership verification as a separate process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Be careful with email addresses
&lt;/h2&gt;

&lt;p&gt;Trim accidental surrounding whitespace. Normalize the domain according to a defined policy. Do not assume every provider treats dots, plus-addressing, or letter case in the local part identically.&lt;/p&gt;

&lt;p&gt;For example, stripping the plus suffix from every address may collapse identifiers that your application should retain separately. Provider-specific behavior is not a universal email rule.&lt;/p&gt;

&lt;p&gt;If your business uses a case-insensitive email matching key, document that decision and keep it separate from the delivery address. Avoid inventing transformations merely to increase the match rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask for country context for phones
&lt;/h2&gt;

&lt;p&gt;A national phone number without a country is ambiguous. Capture country context explicitly when necessary, then use a maintained phone-parsing library with current numbering metadata.&lt;/p&gt;

&lt;p&gt;Keep extensions separate. Do not guess a country from an advertising campaign, browser language, or salesperson preference.&lt;/p&gt;

&lt;p&gt;International formatting can make comparisons easier, but formatting success still does not establish ownership or active service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep a clear data contract
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Original input&lt;/td&gt;
&lt;td&gt;Restricted troubleshooting, if retention is justified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Normalized value&lt;/td&gt;
&lt;td&gt;Consistent display or delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Matching key&lt;/td&gt;
&lt;td&gt;Documented comparison policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verification status&lt;/td&gt;
&lt;td&gt;Separate evidence of ownership&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Avoid writing contact information into general analytics events or unprotected logs. Apply retention and access controls to any original values you retain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test edge cases deliberately
&lt;/h2&gt;

&lt;p&gt;Test surrounding whitespace, international numbers, missing country context, extensions, shared inboxes, and addresses with plus suffixes. Include values your parser cannot confidently interpret.&lt;/p&gt;

&lt;p&gt;An uncertain value should produce a clear correction request or review path. Silently guessing creates convincing-looking data that may route communication to the wrong destination.&lt;/p&gt;

&lt;p&gt;A good normalization process makes matching more consistent without claiming more certainty than the input supports.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>programming</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Backend Cron Alternatives for Scheduled Scripts: HTTP Handoffs for Worker Pool Cleanup</title>
      <dc:creator>FinneganBlake3578</dc:creator>
      <pubDate>Fri, 06 Nov 2026 16:29:42 +0000</pubDate>
      <link>https://dev.to/finneganblake3578/backend-cron-alternatives-for-scheduled-scripts-http-handoffs-for-worker-pool-cleanup-226a</link>
      <guid>https://dev.to/finneganblake3578/backend-cron-alternatives-for-scheduled-scripts-http-handoffs-for-worker-pool-cleanup-226a</guid>
      <description>&lt;p&gt;For scheduled scripts, backend cron alternatives usually come down to one question: should an HTTP call wake a worker pool, or should an external tool own the handoff? For a small B2B SaaS, the least complex choice is a basic cron that puts cleanup work onto a queue. Keep the worker short and idempotent. Add email only at the handoff where an operator actually needs to know something.&lt;/p&gt;

&lt;p&gt;The practical recommendation is to test three paths against the same cleanup workload: infrastructure cron plus your own queue, a visual tool such as Zapier or n8n, and Infrai's single REST API for scheduling, queues, and email. Infrai's relevant advantage is concrete: one key and one bill cover the scheduler, queue, and mailer, so the runner does not need a second credential set. Its public discovery surface is another practical advantage here: the runner can inspect the request schema without installing an SDK, then use plain HTTP from the runtime that already owns cleanup. Pick the path that meets your latency target with the fewest moving parts. The cheapest path is still basic cron on infrastructure you already operate, but it pushes failure handling onto the developer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;th&gt;Main cost in this workflow&lt;/th&gt;
&lt;th&gt;Boundary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Existing infrastructure cron + queue&lt;/td&gt;
&lt;td&gt;A simple, cost-sensitive cleanup&lt;/td&gt;
&lt;td&gt;Your time for retries, idempotency, and alerting&lt;/td&gt;
&lt;td&gt;You own the glue and failure handling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zapier&lt;/td&gt;
&lt;td&gt;Non-developers connecting many apps&lt;/td&gt;
&lt;td&gt;Per-action execution and a visual workflow&lt;/td&gt;
&lt;td&gt;Higher operational fees as executions grow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n&lt;/td&gt;
&lt;td&gt;Teams that want flexible visual connections&lt;/td&gt;
&lt;td&gt;Operating the workflow and its actions&lt;/td&gt;
&lt;td&gt;More workflow surface than a single cleanup needs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One REST API for cron, queue, and email&lt;/td&gt;
&lt;td&gt;A developer-owned path with one credential boundary&lt;/td&gt;
&lt;td&gt;One vendor and one bill to trust&lt;/td&gt;
&lt;td&gt;No DAGs, fan-out joins, or private HTTP targets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For this worker-pool case, I would start with the first row. I would recommend Infrai to a developer-owned SaaS whose cleanup already needs a queue handoff and an operational email, because one credential boundary removes glue from that repeated path. That is a workflow decision, not a claim that one provider wins every benchmark.&lt;/p&gt;

&lt;p&gt;Start small.&lt;/p&gt;

&lt;p&gt;The handoff is the product boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should the experiment measure?
&lt;/h2&gt;

&lt;p&gt;Use a fixed fixture before comparing vendors. Record the number of cleanup items, the oldest item age, the maximum acceptable delay before a worker starts, and the time allowed for an operator notification. Run the same fixture several times at a quiet hour and once near your normal peak. Do not turn the result into a made-up latency promise. The point is to learn which boundary your own workload crosses.&lt;/p&gt;

&lt;p&gt;For example, a cleanup run might find records that have been waiting longer than the retention rule, enqueue one bounded batch, and then send a message containing the run identifier. The useful observation is not that one tool feels faster from a laptop. It is whether the worker begins inside the agreed window, whether a timeout leaves a retry that can be safely replayed, and whether the notification still points to durable state after the first attempt fails. I would write those observations into the same test sheet for every option, including the manual work needed to inspect a dead-lettered message. That last column is easy to skip because it does not appear in a pricing page. It is also where a one-person team often spends the next Saturday.&lt;/p&gt;

&lt;p&gt;I use two pass conditions. The cleanup must finish before its business deadline, and a retry must not apply the same deletion twice. The notification must also be explainable: it should identify the cleanup run without relying on an output log that may be truncated. A path fails if it needs a private endpoint, a task longer than 900 seconds in one cron execution, or a consumer that cannot tolerate at-least-once delivery.&lt;/p&gt;

&lt;p&gt;A useful decision rule is blunt: choose infrastructure cron when the job is one public HTTP call, the team can own retry handling, and the queue is optional. Choose a visual tool when broad app coverage without coding is worth paying per action. Choose the one-key REST path when the scheduler, queue, and mailer belong to the same small backend and the credential handoff is the part creating drag.&lt;/p&gt;

&lt;p&gt;That rule protects revenue per hour. Every extra dashboard is another place I have to inspect during a failed cleanup. I would rather outsource the undifferentiated credential plumbing and spend the saved hour shipping the next weekly feature, but only after the pass conditions are written down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should backend cron alternatives run scheduled scripts over HTTP?
&lt;/h2&gt;

&lt;p&gt;The scheduler should do very little. It calls a public HTTPS endpoint, which enqueues the cleanup request. A worker consumes that request and performs bounded work. If the work can exceed 900 seconds, cron should trigger the queue and a worker should consume it; the scheduler is not the long-running worker. Standard queues are at-least-once, so the cleanup key must be idempotent.&lt;/p&gt;

&lt;p&gt;The following TypeScript keeps the two capability groups on the same base URL and uses the same &lt;code&gt;Authorization&lt;/code&gt; value. It intentionally takes the request bodies from environment variables. The route names are stable, while the request schemas should be obtained from the service's discovery surface rather than guessed in an article. This makes the example runnable with the exact payloads for the account and cleanup implementation. The retry wrapper is deliberately boring: a 429 waits for &lt;code&gt;Retry-After&lt;/code&gt;, and every write carries the same idempotency key when retried.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.infrai.cc/v1&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;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INFRAI_API_KEY&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;cronBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CRON_CREATE_JSON&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;emailBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EMAIL_BATCH_SEND_JSON&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;cronBody&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;emailBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Set INFRAI_API_KEY, CRON_CREATE_JSON, and EMAIL_BATCH_SEND_JSON&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pause&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;callCron&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/cron/create`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;Content-Type&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;application/json&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;Idempotency-Key&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;scheduled-cleanup-worker-pool&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;body&lt;/span&gt;&lt;span class="p"&gt;,&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="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&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;retryAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Retry-After&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/v1/cron/create failed with &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/v1/cron/create remained rate limited after retries&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scheduledCleanup&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;callCron&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cronBody&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;notification&lt;/span&gt; &lt;span class="o"&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emailBody&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;cleanup_schedule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;scheduledCleanup&lt;/span&gt;&lt;span class="p"&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;emailResponse&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/email/batch/send`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;Content-Type&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;application/json&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;Idempotency-Key&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;scheduled-cleanup-worker-pool-email&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;),&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;emailResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/v1/email/batch/send failed with &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;emailResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;emailResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&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="nx"&gt;emailResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sample shows the seam, not a fake universal payload. In production, the cron target should be a public HTTPS URL, and the worker should carry an idempotency key derived from the cleanup run. A cron pause does not backfill missed triggers. Trigger timing also has second-level jitter, so a cleanup that needs an exact transaction boundary belongs elsewhere.&lt;/p&gt;

&lt;p&gt;For a run that needs a delayed queue message, keep &lt;code&gt;delay_seconds&lt;/code&gt; at or below 604800. Keep retention at or below 30 days, and keep a message at or below 256 KB. Acknowledge only after the side effect is durable. There is no Kafka-style replay or multiple consumer-group history here, and the FIFO deduplication window is only five minutes. Those are design inputs, not footnotes.&lt;/p&gt;

&lt;p&gt;The same key and base URL can cover the schedule, queue handoff, and batch email call. That removes a second set of credentials from the runner. The limitation is equally plain: one vendor to trust, one bill, and one outage surface. Infrai is a poor fit for DAG orchestration, fan-out joins, or private-only HTTP targets; use Airflow, Temporal, or infrastructure with network access for those cases. A boundary that reduces operational bookkeeping still deserves a failure plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where do Zapier, n8n, and direct tools fit?
&lt;/h2&gt;

&lt;p&gt;Zapier and n8n are reasonable when the work is mostly connecting applications and the person maintaining it prefers a flexible UI. They make the visual path accessible to non-developers. Their action-based operating model matters, though: frequent cleanup runs or larger-scale usage can create a larger operational fee than a small in-house cron path. That may be a good exchange when the alternative is blocking a release on integration work.&lt;/p&gt;

&lt;p&gt;n8n is the better conversation when the team wants more control over a visual workflow and accepts responsibility for running that workflow. Zapier is a better fit when the priority is a familiar hosted connection catalog. Neither changes the core cleanup question: who owns retries, duplicate delivery, and the public HTTP boundary?&lt;/p&gt;

&lt;p&gt;An Inngest-style scheduler paired with Resend would also be a legitimate specialist stack for a team that wants those separate product boundaries. It would require two signups, two sets of credentials, and glue code to pass the cleanup result from the scheduler or worker into the mailer. The one-key path trades some of that glue for one vendor relationship. The separate stack wins when its workflow semantics or email-specific controls are more important than consolidating the handoff.&lt;/p&gt;

&lt;p&gt;The comparison is not a price spreadsheet. It is a boundary test. A simple cleanup with one HTTP target should not acquire a visual workflow merely because it can. Conversely, a cleanup that touches many applications without coding may justify per-action fees because developer time is the scarce resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is a specialist the better choice?
&lt;/h2&gt;

&lt;p&gt;Do not force a cron-and-queue surface to become an orchestration engine. It has no DAG or workflow orchestration, no native fan-out join, no debounce or throttle primitive, and no topic-style one-to-many delivery. Use Airflow or Temporal territory when those semantics are the job.&lt;/p&gt;

&lt;p&gt;Keep long work behind the queue. Keep private endpoints out of the cron target because the task supports public &lt;code&gt;http_url&lt;/code&gt; delivery only. Push subscriptions also need a public HTTPS endpoint. If a cleanup needs an internal service, put a public, authenticated ingress in front of it or choose infrastructure that can reach the private network.&lt;/p&gt;

&lt;p&gt;Finally, make the failure behavior visible to the operator. Runtime output is retained only for the first 4 KB, so it is a poor audit record for a large cleanup. Store the run identifier and outcome in the application system you already trust. A small amount of explicit state beats reconstructing a failed deletion from a clipped log.&lt;/p&gt;

&lt;p&gt;My decision is therefore conditional. Start with existing cron for the smallest job. Try Infrai when a developer-owned cleanup needs cron, queue, and email behind one key and one bill. Choose Zapier or n8n when a non-developer must own many app connections. Keep Inngest plus Resend, Airflow, or Temporal in the shortlist when their specialist semantics are the requirement.&lt;/p&gt;

&lt;p&gt;If the single-credential boundary fits your cleanup, use the &lt;a href="https://docs.infrai.cc/llms.txt" rel="noopener noreferrer"&gt;scheduling and backend capability index&lt;/a&gt; to inspect the current request schemas before creating the cron task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html" rel="noopener noreferrer"&gt;AWS SQS dead-letter queues&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cloud.google.com/tasks/docs/dual-overview" rel="noopener noreferrer"&gt;Google Cloud Tasks overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/" rel="noopener noreferrer"&gt;Kubernetes CronJob documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.infrai.cc/llms.txt" rel="noopener noreferrer"&gt;Infrai capability index&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>backend</category>
      <category>cron</category>
      <category>alternatives</category>
    </item>
    <item>
      <title>Node.js Observability: Set Up Error Tracking with Trace IDs Explained</title>
      <dc:creator>ValdemarBlack3817</dc:creator>
      <pubDate>Fri, 06 Nov 2026 16:17:09 +0000</pubDate>
      <link>https://dev.to/valdemarblack3817/nodejs-observability-set-up-error-tracking-with-trace-ids-explained-2l25</link>
      <guid>https://dev.to/valdemarblack3817/nodejs-observability-set-up-error-tracking-with-trace-ids-explained-2l25</guid>
      <description>&lt;p&gt;Use one trace context to connect a failed request, its tenant cohort, and the decision to roll an experiment back. Keep the context immutable across the request, attach it to structured error events, and keep cohort labels out of high-cardinality metrics. That combination gives an on-call engineer a useful path from an error to a rollout decision without turning observability data into a second patient-data system.&lt;/p&gt;

&lt;p&gt;I build email, SMS, and OTP flows, so I am suspicious of any design that treats delivery failure as a single log line. A timeout may be a dependency problem, a rate-limit response, or a cohort-specific configuration error. The same caution applies to a healthtech experiment: the trace is evidence for a rollback, not proof that every failure has one cause.&lt;/p&gt;

&lt;p&gt;The trade-off is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What must remain true during a rollback?
&lt;/h2&gt;

&lt;p&gt;The architecture has three invariants. Every inbound request gets a valid correlation context or an explicitly generated one. Every error event carries that context plus a low-cardinality tenant or cohort reference. Finally, the experiment assignment used by the request is recorded with the event, so a later query does not infer treatment from timing or from a mutable tenant table.&lt;/p&gt;

&lt;p&gt;The failure boundaries matter more than the logging library. A worker must receive the context when a job is created, a retry must preserve the original trace relationship without pretending it is the same attempt, and an exception handler must record the error before the context is discarded. Redaction belongs at the event boundary. Identifiers such as patient names, message bodies, and one-time passwords do not belong in a trace label.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Design choice&lt;/th&gt;
&lt;th&gt;Useful property&lt;/th&gt;
&lt;th&gt;Rollback risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Request-scoped context&lt;/td&gt;
&lt;td&gt;Easy to join application errors to one request&lt;/td&gt;
&lt;td&gt;Lost when work crosses a queue unless propagated explicitly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Explicit event envelope&lt;/td&gt;
&lt;td&gt;Stable fields for search, retention, and redaction&lt;/td&gt;
&lt;td&gt;More code at every async boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metric labels for every identifier&lt;/td&gt;
&lt;td&gt;Fast aggregate dashboards&lt;/td&gt;
&lt;td&gt;Cardinality grows with tenants, requests, or trace IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trace and log correlation&lt;/td&gt;
&lt;td&gt;Detailed path plus searchable error record&lt;/td&gt;
&lt;td&gt;Requires consistent propagation and clock-aware interpretation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row is the trap. Prometheus instrumentation guidance warns against labels whose values have high cardinality. A trace ID is excellent for finding one execution and poor as a metric label. Store it in logs or trace events; aggregate metrics by a bounded outcome such as &lt;code&gt;error_type&lt;/code&gt;, &lt;code&gt;cohort&lt;/code&gt;, or dependency class.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should Node.js error tracking cross an async boundary?
&lt;/h2&gt;

&lt;p&gt;Treat the trace context as data, not as ambient magic. In Node.js, request-local storage can make handlers convenient, but queue payloads, scheduled jobs, and outbound calls still need an explicit propagation rule. The event envelope should contain the trace ID, the current span ID when available, tenant or cohort key, experiment revision, attempt number, and a normalized error type. It should not contain the full exception payload by default.&lt;/p&gt;

&lt;p&gt;Here is the critical path in deliberately small pseudocode. The surrounding application can use its own Node.js framework; the contract is the part worth testing.&lt;/p&gt;

&lt;p&gt;Test the boundary.&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;handle_request&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;experiment&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;read_or_create_trace_context&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;assignment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;experiment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&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;tenant_id&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_health_operation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&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;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cohort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="o"&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;trace_id&lt;/span&gt;&lt;span class="p"&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;result&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;event&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;event&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;application_error&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;trace_id&lt;/span&gt;&lt;span class="sh"&gt;"&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;trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;span_id&lt;/span&gt;&lt;span class="sh"&gt;"&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;span_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_key&lt;/span&gt;&lt;span class="sh"&gt;"&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;tenant_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cohort&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;experiment_revision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;experiment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempt&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;normalize_error_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;emit_redacted_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail is &lt;code&gt;experiment_revision&lt;/code&gt;. If the cohort assignment changes while an incident is investigated, a bare tenant ID cannot tell you which behavior the request actually exercised. I also record an attempt number because a retry is a separate observation. Otherwise, a dependency that fails three times looks like three independent tenants in a noisy dashboard.&lt;/p&gt;

&lt;p&gt;That distinction saves the rollback review.&lt;/p&gt;

&lt;p&gt;For an outbound call, propagate a standards-based trace context and create a child span for the dependency operation. For a queue, put the parent relationship and the business fields in a versioned envelope. On consumption, create a new processing span linked to the enqueue operation. This keeps the causal story readable without pretending that minutes of queue delay happened inside the original HTTP span.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option survives a cohort experiment?
&lt;/h2&gt;

&lt;p&gt;There are two reasonable storage shapes. A log-first design writes structured error events and uses traces for timing. A trace-first design makes the trace the primary investigation record and derives error views from it. I prefer the first for rollback decisions in healthtech systems because retention, redaction, and access control for an error event can be reviewed as a bounded contract. Traces remain valuable for dependency timing, but they should not become the only copy of the decision evidence.&lt;/p&gt;

&lt;p&gt;The choice is operational, not ideological. If a team has mature trace sampling and a well-defined error-event stream, either shape can work. Sampling is the sharp edge: a sampled-out successful request is usually fine, while a sampled-out error can remove the comparison point needed to judge a rollout. Error events should therefore have an independent retention and delivery policy, with a bounded payload and a clear failure mode when the telemetry sink is unavailable. The business request must not fail merely because telemetry failed. The limitation is that a telemetry pipeline can preserve correlation while still losing events during an outage, so rollback evidence needs an explicit completeness check.&lt;/p&gt;

&lt;p&gt;Do not use a dashboard threshold as the rollback algorithm by itself. Compare treatment and control using the same observation window, normalize by eligible requests, and split results by dependency class and cohort. A spike in one tenant can indicate a tenant configuration issue rather than a bad experiment. A spike only after a dependency timeout deserves a different response from a spike in validation failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did I reject, and when is it still valid?
&lt;/h2&gt;

&lt;p&gt;I initially thought putting &lt;code&gt;trace_id&lt;/code&gt; into every metric label would make the first incident easier. It feels convenient when searching for one request is the immediate need. It becomes an operational liability once traffic grows: each request creates another time series, dashboards become expensive to query, and the aggregate signal gets harder to read. The valid use case is a short-lived local diagnostic metric with a tightly bounded identifier set, not a production request counter. The trade-off is search convenience today versus a usable aggregate tomorrow.&lt;/p&gt;

&lt;p&gt;I also rejected copying raw exception text into the event as the primary field. Exception messages often contain input fragments or dependency responses. Normalize the type and preserve a redacted, access-controlled detail field only when the investigation requires it. That is a compliance boundary, not a formatting preference.&lt;/p&gt;

&lt;p&gt;A rollback-safe review should answer four questions: can we identify the exact experiment revision, can we join the error to its trace without exposing sensitive payloads, can we distinguish retries from new requests, and can we compare cohorts without trace-level metric cardinality? If any answer is no, fix the evidence path before trusting the experiment result.&lt;/p&gt;

&lt;p&gt;The minimum implementation is therefore modest: propagate a trace context, emit a redacted structured error event, record cohort and revision, and aggregate metrics only on bounded dimensions. In a healthtech rollout, that is more useful than a dense dashboard full of identifiers. It leaves the team with a defensible rollback signal and a smaller blast radius when the next dependency behaves badly.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;W3C Trace Context Recommendation: &lt;a href="https://www.w3.org/TR/trace-context/" rel="noopener noreferrer"&gt;https://www.w3.org/TR/trace-context/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenTelemetry, Trace Context: &lt;a href="https://opentelemetry.io/docs/concepts/context-propagation/" rel="noopener noreferrer"&gt;https://opentelemetry.io/docs/concepts/context-propagation/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Prometheus, Instrumentation Best Practices: &lt;a href="https://prometheus.io/docs/practices/instrumentation/" rel="noopener noreferrer"&gt;https://prometheus.io/docs/practices/instrumentation/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenTelemetry, Sampling: &lt;a href="https://opentelemetry.io/docs/concepts/sampling/" rel="noopener noreferrer"&gt;https://opentelemetry.io/docs/concepts/sampling/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>observability</category>
      <category>healthtech</category>
      <category>backend</category>
    </item>
    <item>
      <title>Refunded or disputed, but the customer still has access? Your Stripe webhook only listens for the purchase</title>
      <dc:creator>WebhookProof</dc:creator>
      <pubDate>Fri, 06 Nov 2026 15:13:47 +0000</pubDate>
      <link>https://dev.to/webhookproof/refunded-or-disputed-but-the-customer-still-has-access-your-stripe-webhook-only-listens-for-the-5hfj</link>
      <guid>https://dev.to/webhookproof/refunded-or-disputed-but-the-customer-still-has-access-your-stripe-webhook-only-listens-for-the-5hfj</guid>
      <description>&lt;p&gt;A lot of paywalls handle exactly one Stripe event. &lt;code&gt;checkout.session.completed&lt;/code&gt; arrives, the handler sets &lt;code&gt;paid = true&lt;/code&gt;, and that's the end of it. Nothing in the code ever sets it back.&lt;/p&gt;

&lt;p&gt;So a refund goes out, or a customer's bank opens a dispute, or a subscription ends, and the database still says paid. The app keeps the door open for someone who's no longer paying. This post covers the events that should take access away, how to tie them back to the right user, and the edge cases worth deciding on up front. Every claim links to the doc it comes from (see Sources at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell if access is one-way
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the webhook endpoint in the Stripe Dashboard and look at the events it's subscribed to. If the list is only checkout or payment-success events, Stripe never tells the app about refunds or disputes.&lt;/li&gt;
&lt;li&gt;Refund a test-mode purchase, then reload the app as that test user. If the paid content is still there, access only ever goes one direction.&lt;/li&gt;
&lt;li&gt;Search the handler for &lt;code&gt;charge.refunded&lt;/code&gt;, &lt;code&gt;charge.dispute.created&lt;/code&gt; and &lt;code&gt;customer.subscription.deleted&lt;/code&gt;. If none of them appear, nothing revokes access.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The events that should take access away
&lt;/h2&gt;

&lt;p&gt;From Stripe's list of event types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;charge.refunded&lt;/code&gt;: "Occurs whenever a charge is refunded, including partial refunds."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;charge.dispute.created&lt;/code&gt;: "Occurs whenever a customer disputes a charge with their bank."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;charge.dispute.closed&lt;/code&gt;: "Occurs when a dispute is closed and the dispute status changes to &lt;code&gt;lost&lt;/code&gt;, &lt;code&gt;warning_closed&lt;/code&gt;, or &lt;code&gt;won&lt;/code&gt;." This is the one to use for giving access back after a dispute is won.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;refund.failed&lt;/code&gt;: "Occurs whenever a refund has failed." Stripe's refund docs say that in that case "you need to arrange an alternative way to provide your customer with a refund."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For subscriptions, Stripe's cancellation docs describe &lt;code&gt;customer.subscription.deleted&lt;/code&gt; as "Sent when a subscription is canceled. The cancellation can result from a direct call to delete the subscription or when a subscription with &lt;code&gt;cancel_at_period_end: true&lt;/code&gt; reaches the end of its billing period."&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;cancel_at_period_end&lt;/code&gt; doesn't end anything yet. The same docs say &lt;code&gt;customer.subscription.updated&lt;/code&gt; is "Sent for any subscription update, including when &lt;code&gt;cancel_at_period_end&lt;/code&gt; is set to &lt;code&gt;true&lt;/code&gt;." The customer has paid through the end of the period, so keep access until the deleted event arrives.&lt;/p&gt;

&lt;p&gt;Each of these has to be added to the endpoint's event list, in the Dashboard or through &lt;code&gt;enabled_events&lt;/code&gt; on the endpoint. Stripe only sends the events an endpoint is subscribed to. Stripe's refund docs also recommend that you "verify webhook signatures" for these events, which matters because the same handler that revokes access also grants it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Map the event back to the user
&lt;/h2&gt;

&lt;p&gt;Refund and dispute events carry a Charge or a Dispute, not the Checkout Session that granted access. Both point back to the payment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On the Charge object, &lt;code&gt;payment_intent&lt;/code&gt; is the "ID of the PaymentIntent associated with this charge."&lt;/li&gt;
&lt;li&gt;On the Dispute object, &lt;code&gt;payment_intent&lt;/code&gt; is the "ID of the PaymentIntent that’s disputed," and &lt;code&gt;charge&lt;/code&gt; is the "ID of the charge that’s disputed."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Checkout Session object has a &lt;code&gt;payment_intent&lt;/code&gt; field too. So at fulfillment time, store the session's &lt;code&gt;payment_intent&lt;/code&gt; next to the user. Revoking then becomes a single lookup.&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="c1"&gt;// Inside a verified webhook handler (signature already checked on the raw body)&lt;/span&gt;
&lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.completed&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;grantAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client_reference_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;paymentIntent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_intent&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;charge.refunded&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// refunded is true only for a full refund; decide what a partial refund means&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;revokeByPaymentIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refunded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;charge.dispute.created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;revokeByPaymentIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;disputed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;charge.dispute.closed&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;won&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;restoreByPaymentIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_intent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;customer.subscription.deleted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;revokeBySubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canceled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&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;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Decide the edge cases before they happen
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Partial refunds.&lt;/strong&gt; &lt;code&gt;charge.refunded&lt;/code&gt; fires for those too. On the Charge, &lt;code&gt;refunded&lt;/code&gt; means "Whether the charge has been fully refunded. If the charge is only partially refunded, this attribute will still be false," and &lt;code&gt;amount_refunded&lt;/code&gt; holds the refunded amount. Pick a rule, such as revoking only on a full refund, and write it down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inquiries.&lt;/strong&gt; Stripe's dispute docs explain that "Some card networks initiate a preliminary phase before creating a formal dispute," which Stripe calls an inquiry, and that in the API an inquiry's status "is prepended with &lt;code&gt;warning&lt;/code&gt;," as in &lt;code&gt;warning_needs_response&lt;/code&gt;. The same page notes the bank often asks "because the cardholder doesn’t recognize the transaction description." To avoid cutting off a customer over a confusing statement line, revoke on formal dispute statuses and leave &lt;code&gt;warning_&lt;/code&gt; statuses alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disputes that are won.&lt;/strong&gt; When &lt;code&gt;charge.dispute.closed&lt;/code&gt; arrives with status &lt;code&gt;won&lt;/code&gt;, restore access, as in the &lt;code&gt;switch&lt;/code&gt; above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that make revocation fail quietly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access granted by the success page.&lt;/strong&gt; If &lt;code&gt;/success&lt;/code&gt; unlocks access on its own, a webhook can't keep it revoked. Grant from the webhook and have the page only read the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate deliveries.&lt;/strong&gt; The same event can arrive more than once, so revoke and restore should be safe to run twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An endpoint Stripe can't reach.&lt;/strong&gt; Then neither grants nor revokes arrive at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Clean up the customers who already kept access
&lt;/h2&gt;

&lt;p&gt;Stripe's refund docs point to a Dashboard view of refunded payments. To catch disputes as well, page through charges with the API and check &lt;code&gt;refunded&lt;/code&gt; and &lt;code&gt;disputed&lt;/code&gt; ("Whether the charge has been disputed") on each one. Revoke each match with the same function the webhook now calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The endpoint subscribes to &lt;code&gt;charge.refunded&lt;/code&gt;, &lt;code&gt;charge.dispute.created&lt;/code&gt;, &lt;code&gt;charge.dispute.closed&lt;/code&gt; and &lt;code&gt;customer.subscription.deleted&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Fulfillment stores the Checkout Session's &lt;code&gt;payment_intent&lt;/code&gt; with the user&lt;/li&gt;
&lt;li&gt;[ ] Revoke runs only for a full refund, or a written rule covers partial ones&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;warning_&lt;/code&gt; inquiry statuses are handled on purpose&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;cancel_at_period_end&lt;/code&gt; keeps access until &lt;code&gt;customer.subscription.deleted&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] A won dispute restores access&lt;/li&gt;
&lt;li&gt;[ ] Past refunds and disputes have been cleaned up&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this helps if Stripe can't reach the endpoint in the first place. WebhookProof's free checker sends 3 harmless test requests to a webhook URL you own and reports whether Stripe can reach it and whether unsigned requests are rejected. The full guide, with the revoke logic and a cleanup approach, is here: &lt;a href="https://webhookproof.com/guides/stripe-refund-or-dispute-does-not-revoke-access" rel="noopener noreferrer"&gt;Refunded or disputed Stripe payment still has access?&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-11-04.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe API, Types of events (&lt;code&gt;charge.refunded&lt;/code&gt;, &lt;code&gt;charge.dispute.created&lt;/code&gt;, &lt;code&gt;charge.dispute.closed&lt;/code&gt;, &lt;code&gt;refund.failed&lt;/code&gt;): &lt;a href="https://docs.stripe.com/api/events/types" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/events/types&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Refund and cancel payments (alternative refund when &lt;code&gt;refund.failed&lt;/code&gt;, verify webhook signatures, Dashboard view of refunded payments): &lt;a href="https://docs.stripe.com/refunds" rel="noopener noreferrer"&gt;https://docs.stripe.com/refunds&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, Cancel subscriptions (&lt;code&gt;customer.subscription.deleted&lt;/code&gt;, &lt;code&gt;customer.subscription.updated&lt;/code&gt; when &lt;code&gt;cancel_at_period_end&lt;/code&gt; is set): &lt;a href="https://docs.stripe.com/billing/subscriptions/cancel" rel="noopener noreferrer"&gt;https://docs.stripe.com/billing/subscriptions/cancel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe API, The Charge object (&lt;code&gt;payment_intent&lt;/code&gt;, &lt;code&gt;refunded&lt;/code&gt;, &lt;code&gt;amount_refunded&lt;/code&gt;, &lt;code&gt;disputed&lt;/code&gt;): &lt;a href="https://docs.stripe.com/api/charges/object" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/charges/object&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe API, The Dispute object (&lt;code&gt;payment_intent&lt;/code&gt;, &lt;code&gt;charge&lt;/code&gt;): &lt;a href="https://docs.stripe.com/api/disputes/object" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/disputes/object&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe API, The Checkout Session object (&lt;code&gt;payment_intent&lt;/code&gt;): &lt;a href="https://docs.stripe.com/api/checkout/sessions/object" rel="noopener noreferrer"&gt;https://docs.stripe.com/api/checkout/sessions/object&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe, How disputes work (inquiries, &lt;code&gt;warning_&lt;/code&gt; statuses): &lt;a href="https://docs.stripe.com/disputes/how-disputes-work" rel="noopener noreferrer"&gt;https://docs.stripe.com/disputes/how-disputes-work&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>stripe</category>
      <category>webhooks</category>
      <category>backend</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
