<?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: Paul Arun Prasath</title>
    <description>The latest articles on DEV Community by Paul Arun Prasath (@paul_arunprasath_434e15c).</description>
    <link>https://dev.to/paul_arunprasath_434e15c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3754104%2Fb47813ab-c7fe-42b1-a286-39efc6da766a.jpg</url>
      <title>DEV Community: Paul Arun Prasath</title>
      <link>https://dev.to/paul_arunprasath_434e15c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/paul_arunprasath_434e15c"/>
    <language>en</language>
    <item>
      <title>Polly's maintenance fee starts 16 November. Is your .NET app affected?</title>
      <dc:creator>Paul Arun Prasath</dc:creator>
      <pubDate>Mon, 02 Nov 2026 12:22:31 +0000</pubDate>
      <link>https://dev.to/paul_arunprasath_434e15c/pollys-maintenance-fee-starts-16-november-is-your-net-app-affected-1176</link>
      <guid>https://dev.to/paul_arunprasath_434e15c/pollys-maintenance-fee-starts-16-november-is-your-net-app-affected-1176</guid>
      <description>&lt;p&gt;&lt;em&gt;Check your solution in one command, along with MediatR, AutoMapper, MassTransit and FluentAssertions.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On 14 July 2026 the Polly project announced that it's adopting the &lt;strong&gt;Open Source Maintenance Fee (OSMF)&lt;/strong&gt;. Payment requests start on &lt;strong&gt;16 November 2026&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Polly is everywhere in .NET. The &lt;code&gt;Polly&lt;/code&gt; package alone has more than 1.7 billion downloads, and Microsoft's own resilience packages are built on it. So the question many teams are asking right now is simple: &lt;strong&gt;do we have to pay?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For most teams the answer is no, but it depends on two details that are easy to miss. In this post I'll explain both, then show how to check every project in your solution in one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changes
&lt;/h2&gt;

&lt;p&gt;Here's what the Polly announcement says, quoted directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Price and threshold:&lt;/strong&gt; "The Maintenance Fee is US $20 per month and applies to any company that earns at least US $20,000 from at least one product or project that uses Polly."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which versions:&lt;/strong&gt; "The fee applies only to the next major version of Polly released after November 16, 2026." And: "Existing versions of Polly are not subject to the fee."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which references:&lt;/strong&gt; "Only direct references to Polly are subject to the fee. If Polly is a dependency of a different library, such as Microsoft.Extensions.Resilience or Microsoft.Extensions.Http.Resilience, the fee does not apply."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The source code stays open under its existing BSD-3-Clause licence. Individuals, students, hobbyists and organisations below the revenue threshold don't pay.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two details that decide whether you're affected
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Version.&lt;/strong&gt; Polly 8.x, which is what you're almost certainly using today, is not affected. The fee applies to the next major version (Polly 9) released after 16 November. So upgrading carelessly is the real risk, especially with Dependabot, Renovate or a casual &lt;code&gt;dotnet add package Polly&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Direct vs transitive.&lt;/strong&gt; If you only get Polly through Microsoft's resilience packages, the fee doesn't apply. A typical example is &lt;code&gt;AddStandardResilienceHandler()&lt;/code&gt; from &lt;code&gt;Microsoft.Extensions.Http.Resilience&lt;/code&gt;. Your app still loads &lt;code&gt;Polly.Core&lt;/code&gt;, but you don't reference it yourself.&lt;/p&gt;

&lt;p&gt;Both are hard to see by eye in a real solution:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Using statements don't tell you.&lt;/strong&gt; The official OSMF guidance warns about this itself: &lt;code&gt;using Polly;&lt;/code&gt; compiles whether Polly comes in directly or through another package.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;References hide in many places.&lt;/strong&gt; They can be in &lt;code&gt;Directory.Build.props&lt;/code&gt;, in &lt;code&gt;Directory.Packages.props&lt;/code&gt; with Central Package Management, or in one test project nobody looks at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Licence scanners won't flag it.&lt;/strong&gt; Polly's licence label stays BSD-3-Clause, so tools that compare licences against an allowed list see nothing unusual.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The OSMF website's advice for .NET is to open every &lt;code&gt;.csproj&lt;/code&gt;, collect the package references and check each one by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check it in one command
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl5gvgf4ffh2wtlbubhqw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl5gvgf4ffh2wtlbubhqw.png" alt="![PackageTerms banner: find the NuGet packages that went commercial or added a fee. Install with dotnet tool install -g PackageTerms](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/prmkhk8croub5do0l6yg.png)&lt;br&gt;
" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I built a small open-source tool for exactly this: &lt;strong&gt;PackageTerms&lt;/strong&gt;. It reads the restore output NuGet already produces (&lt;code&gt;obj/project.assets.json&lt;/code&gt;), so it sees the exact versions your projects resolve, including transitive ones, and tells you which packages changed their terms.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; PackageTerms
package-terms scan MySolution.sln
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Here's a sample solution with an API project and a test project:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scanned 2 project(s).

[COMMERCIAL] MediatR 13.1.0 (direct)
  From v13, MediatR is dual-licensed under RPL-1.5 and the Lucky Penny Software commercial licence. Earlier versions keep their original Apache-2.0 terms.
  Last free version: 12.5.0
  Alternative: Mediator.SourceGenerator: Source-generated mediator by Martin Othamar (MIT).
  Projects: Shop.Api.csproj

[COMMERCIAL] MediatR 13.1.0 (transitive via Shop.Api (project))
  ...
  Projects: Shop.Tests.csproj

[UPGRADE RISK] Polly 8.9.0 (direct)
  Your version is still free, but Polly 9.0.0 and later carries a maintenance fee (from 2026-11-16). Check the terms before upgrading.
  Who pays: Companies earning at least US$20,000 from a product or project that uses Polly: US$20 per month. Direct references only; Polly brought in by another library (for example Microsoft.Extensions.Http.Resilience) is exempt.
  Projects: Shop.Api.csproj
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;And here's a project that only uses Microsoft's resilience package:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[NO ACTION] Polly.Core 8.4.2 (transitive via Microsoft.Extensions.Http.Resilience → Microsoft.Extensions.Resilience → Polly.Extensions)
  Polly's new terms cover direct references only, so this transitive reference is exempt.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;For each package, the output tells you three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Are you affected?&lt;/strong&gt; It shows your exact version and whether the reference is direct, or the chain of packages or projects it comes through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What changed and when&lt;/strong&gt;, with a link to the official announcement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What to do&lt;/strong&gt;: the last free version and free alternatives.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  It's not just Polly
&lt;/h2&gt;

&lt;p&gt;Polly is the newest change, but several popular .NET libraries changed their terms in the last two years. PackageTerms checks these too:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;th&gt;Covered versions&lt;/th&gt;
&lt;th&gt;Last free version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Polly&lt;/td&gt;
&lt;td&gt;Maintenance fee, direct references only&lt;/td&gt;
&lt;td&gt;9.0 and later&lt;/td&gt;
&lt;td&gt;8.x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MediatR&lt;/td&gt;
&lt;td&gt;Commercial licence (free Community edition under US$5M revenue)&lt;/td&gt;
&lt;td&gt;13.0 and later&lt;/td&gt;
&lt;td&gt;12.5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AutoMapper&lt;/td&gt;
&lt;td&gt;Commercial licence (free Community edition under US$5M revenue)&lt;/td&gt;
&lt;td&gt;15.0 and later&lt;/td&gt;
&lt;td&gt;14.0.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MassTransit&lt;/td&gt;
&lt;td&gt;Commercial (Massient licence)&lt;/td&gt;
&lt;td&gt;9.0 and later&lt;/td&gt;
&lt;td&gt;8.5.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FluentAssertions&lt;/td&gt;
&lt;td&gt;Paid licence for commercial use (Xceed)&lt;/td&gt;
&lt;td&gt;8.0 and later&lt;/td&gt;
&lt;td&gt;7.2.2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every entry links to the project's own announcement or licence, and the data is open on GitHub. If you know of a package that's missing, please open an issue with a link to the official source.&lt;/p&gt;
&lt;h2&gt;
  
  
  Catch it in CI, before it reaches main
&lt;/h2&gt;

&lt;p&gt;Upgrades usually arrive through automated pull requests. Add one step to your pipeline and the build fails if an affected package slips in:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dotnet tool install -g PackageTerms&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;package-terms scan --fail-on affected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Use &lt;code&gt;--fail-on upgrade-risk&lt;/code&gt; to be stricter, or &lt;code&gt;--format markdown&lt;/code&gt; to post the result as a pull-request comment.&lt;/p&gt;
&lt;h2&gt;
  
  
  What to do if you are affected
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Polly, direct reference, organisation above US$20K revenue:&lt;/strong&gt; stay on Polly 8.x (it's not subject to the fee), budget US$20 per month when you move to Polly 9, or use &lt;code&gt;Microsoft.Extensions.Http.Resilience&lt;/code&gt; for HTTP resilience, which brings in Polly transitively and is exempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MediatR, AutoMapper, MassTransit, FluentAssertions:&lt;/strong&gt; check whether you qualify for a free tier, buy a licence, pin to the last free version, or move to an alternative such as Mediator.SourceGenerator, Mapperly, Rebus or Shouldly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you decide, decide it deliberately rather than finding out from an automated upgrade.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Install: &lt;code&gt;dotnet tool install -g PackageTerms&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Source and dataset: &lt;a href="https://github.com/paulap887/PackageTerms" rel="noopener noreferrer"&gt;https://github.com/paulap887/PackageTerms&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Polly announcement: &lt;a href="https://thepollyproject.org/2026/07/14/polly-osmf-announcement.html" rel="noopener noreferrer"&gt;https://thepollyproject.org/2026/07/14/polly-osmf-announcement.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/paulap887" rel="noopener noreferrer"&gt;
        paulap887
      &lt;/a&gt; / &lt;a href="https://github.com/paulap887/PackageTerms" rel="noopener noreferrer"&gt;
        PackageTerms
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Find NuGet dependencies that went commercial or added a maintenance fee (Polly, MediatR, AutoMapper, MassTransit, FluentAssertions). A dotnet tool.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;PackageTerms&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Find the NuGet packages in your .NET solution that went commercial or added a maintenance fee, before they surprise you.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Several popular .NET libraries have changed their terms: MediatR and AutoMapper went commercial, MassTransit v9 and FluentAssertions v8 need paid licences for commercial use, and Polly adds a maintenance fee for its next major version. Licence scanners that only read a package's licence label can miss these changes. Polly's licence, for example, stays BSD-3-Clause. They also don't tell you which version changed, whether the change applies to you, or what to do about it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;package-terms&lt;/code&gt; scans your solution and tells you, for each affected package:&lt;/p&gt;


&lt;ul&gt;

&lt;li&gt;

&lt;strong&gt;Whether you're affected&lt;/strong&gt;: the exact version you resolve, and whether you reference it directly or it comes in through another package or project&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;What changed and when&lt;/strong&gt;, with a link to the official announcement&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;What to do&lt;/strong&gt;: the last free version…&lt;/li&gt;

&lt;/ul&gt;&lt;/div&gt;
&lt;br&gt;
  &lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/paulap887/PackageTerms" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;If it saves you a surprise invoice, a ⭐ on GitHub helps other .NET developers find it. If it misses a package or gets something wrong, an issue with a link to the official source is even better. &lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>4 HybridCache Gotchas I Only Found When I Tested My Own Sample</title>
      <dc:creator>Paul Arun Prasath</dc:creator>
      <pubDate>Mon, 05 Oct 2026 07:15:40 +0000</pubDate>
      <link>https://dev.to/paul_arunprasath_434e15c/4-hybridcache-gotchas-i-only-found-when-i-tested-my-own-sample-2bhg</link>
      <guid>https://dev.to/paul_arunprasath_434e15c/4-hybridcache-gotchas-i-only-found-when-i-tested-my-own-sample-2bhg</guid>
      <description>&lt;p&gt;My "under 5 ms" demo was running without an L2, hung on Redis and served stale data. I didn't notice any of it until I wrote tests.&lt;/p&gt;

&lt;p&gt;Recently I published &lt;a href="https://levelup.gitconnected.com/serve-api-responses-in-under-5ms-hybridcache-in-net-8a2457a5e639" rel="noopener noreferrer"&gt;Serve API Responses in Under 5ms: HybridCache in .NET&lt;/a&gt;. The demo worked, the numbers looked great, and I was pretty happy with it.&lt;/p&gt;

&lt;p&gt;Then I decided the repo deserved real tests, a CI pipeline with Redis and proper benchmarks instead of a curl script. I expected that to take an evening and confirm what I already knew.&lt;/p&gt;

&lt;p&gt;It didn't. I found four problems. One was a plain bug in my code. The other three were HybridCache behaviours I simply hadn't understood. I never noticed any of them while clicking around by hand, and every one of them would have bitten me in production.&lt;/p&gt;

&lt;p&gt;All the fixes are in the companion repo, and each one has a test that fails against the old code.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AddDistributedMemoryCache() doesn't give you an L2
My Program.cs looked like most HybridCache samples you'll find online:
&lt;/li&gt;
&lt;/ol&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="nf"&gt;AddDistributedMemoryCache&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// my "L2"&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;AddHybridCache&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My README even said AddDistributedMemoryCache() was "used as the L2 store". The logic seemed fine to me. HybridCache uses whatever IDistributedCache you register, so this should give me two levels of cache without running Redis on my laptop.&lt;/p&gt;

&lt;p&gt;So I wrote a test for it. Two HybridCache instances play two pods. Each has its own L1, and they share one L2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sharedL2&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;MemoryDistributedCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;MemoryDistributedCacheOptions&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;podA&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;CreateHybridCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sharedL2&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;podB&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;CreateHybridCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sharedL2&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;podA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetOrCreateAsync&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;Factory&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// miss: factory runs, fills L1(A) and L2&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;podA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetOrCreateAsync&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;Factory&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// L1(A) hit&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;podB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetOrCreateAsync&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;Factory&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// L1(B) is empty, so this should come from L2&lt;/span&gt;

&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;factoryCalls&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Actual: 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The factory ran twice. Pod B found nothing in L2, because nothing had ever been written there.&lt;/p&gt;

&lt;p&gt;I didn't quite believe it, so I went and read the source. It's right there in DefaultHybridCache's constructor:&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;// ignore L2 if it is really just the same L1, wrapped&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_backendCache&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_backendCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetType&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MemoryDistributedCache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;_localCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetType&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MemoryCache&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_backendCache&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="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you think about it, it's a reasonable optimisation. MemoryDistributedCache lives in the same process, so using it as L2 would just mean serializing a second copy of L1. HybridCache notices and switches L2 off.&lt;/p&gt;

&lt;p&gt;The awkward part for me: my sample had been running with L1 only the whole time. The L1 → L2 → factory flow I walked through in the article never actually happened unless you ran Redis.&lt;/p&gt;

&lt;p&gt;If you want a real L2, use an out-of-process store like Redis, SQL Server or Postgres. If you only want an in-process L2 for tests, wrap MemoryDistributedCache in a small pass-through class. The check compares exact types, so the wrapper counts as a real L2:&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;class&lt;/span&gt; &lt;span class="nc"&gt;PassThroughDistributedCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IDistributedCache&lt;/span&gt; &lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IDistributedCache&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;byte&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="kt"&gt;string&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;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;inner&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="n"&gt;key&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;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;key&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;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&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;inner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&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;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Set, SetAsync, Refresh, RefreshAsync, Remove and RemoveAsync delegate the same way&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The official HybridCache docs didn't mention this, so I sent a &lt;a href="https://github.com/dotnet/AspNetCore.Docs/pull/37758" rel="noopener noreferrer"&gt;pull request&lt;/a&gt; to add a note. Microsoft merged it, and it's now live on &lt;a href="https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid?view=aspnetcore-10.0#cache-storage" rel="noopener noreferrer"&gt;Microsoft Learn&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnq2hnn4g92g7mgnn5ycw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnq2hnn4g92g7mgnn5ycw.png" alt=" " width="800" height="722"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hopefully it saves someone else the head-scratching.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Redis and HybridCache deadlocked on the first request
Fine, I thought. I'll wire up Redis properly.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;redis&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;"Redis"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redis&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;AddStackExchangeRedisCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&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="n"&gt;redis&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;AddHybridCache&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My Redis tests went green. Then I started the actual API against Redis, sent GET /products, and... nothing. The app had started normally. The connection was accepted. The request just sat there, with no exception, no log line and no timeout.&lt;/p&gt;

&lt;p&gt;The tests had passed for a boring reason. They created the cache with new RedisCache(...), while the API used AddStackExchangeRedisCache(...). That one difference was the whole problem.&lt;/p&gt;

&lt;p&gt;I took a stack dump with dotnet-stack, and the loop was easy to spot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
RedisCacheImpl..ctor(IOptions&amp;lt;RedisCacheOptions&amp;gt;, IServiceProvider)
  RedisCacheImpl.IsHybridCacheDefined(IServiceProvider)
    DefaultHybridCache..ctor(IOptions&amp;lt;HybridCacheOptions&amp;gt;, IServiceProvider)
      RedisCacheImpl..ctor(...)
        RedisCacheImpl.IsHybridCacheDefined(...)
          DefaultHybridCache..ctor(...)
            ...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Building HybridCache resolves the IDistributedCache. In Microsoft.Extensions.Caching.StackExchangeRedis 9.0.3, the Redis cache's constructor checks whether HybridCache is registered by... resolving HybridCache. Two singletons, each waiting for the other to finish being created. Forever.&lt;/p&gt;

&lt;p&gt;It's a known bug, already reported and fixed upstream in dotnet/aspnetcore#60894. Upgrading the Redis package sorted it out. I confirmed that 9.0.20 works; the issue will tell you the first patched version.&lt;/p&gt;

&lt;p&gt;What I took away from this one:&lt;/p&gt;

&lt;p&gt;If you use HybridCache with AddStackExchangeRedisCache, check which Redis package version you're on.&lt;br&gt;
Test the app the way it actually runs. My tests built RedisCache by hand and never went near the DI path that deadlocked. The regression test I added runs the whole API through WebApplicationFactory with Redis configured, and fails if GET /products takes longer than 10 seconds.&lt;br&gt;
When something hangs and logs nothing, take a stack dump before you start guessing. One command took me from "is Redis broken?" to the exact constructor cycle.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The L2 write can land after GetOrCreateAsync returns
With Redis fixed, I pointed my two-pod test from gotcha 1 at real Redis. It still failed. Pod B called its factory again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When I checked Redis afterwards, the keys were there. They just weren't there yet when pod B asked. In my tests, HybridCache handed the value back before its Redis write had finished. My in-memory wrapper wrote synchronously, so it had hidden this completely.&lt;/p&gt;

&lt;p&gt;For the test, the fix was to wait for the entry to show up in L2 before asking the second instance:&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;// HybridCache can return the value before its L2 write completes,&lt;/span&gt;
&lt;span class="c1"&gt;// so wait for the entry to land in L2 before asking the second instance.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;deadline&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;AddSeconds&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;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;sharedL2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&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="k"&gt;is&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="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;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deadline&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="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bigger lesson is about production. If two instances ask for the same key at almost the same moment, both can miss L2 and both can run the factory. Stampede protection works inside one process. It doesn't cover your whole fleet.&lt;/p&gt;

&lt;p&gt;So make your factories safe to run more than once, and cheap enough that it doesn't matter much when they do. If something really must run only once across all your instances, you need a distributed lock or a different design, because HybridCache won't do that for you. And in tests, don't assume an L2 write is visible straight away.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tag invalidation only clears what you actually tagged
This one's entirely my fault, but I suspect it's a common mistake.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My original service tagged the list with products, and each item with products plus product:{id}:&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;// list&lt;/span&gt;
&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetOrCreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"products:all"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"products"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// item&lt;/span&gt;
&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetOrCreateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"product:&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="s"&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;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"products"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;$"product:&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="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// update&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RemoveByTagAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"product:&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="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "invalidates both the item and any list"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that comment. It's wrong. The list never had the product:{id} tag, so updating product 1 cleared the cached item while GET /products kept showing the old name for up to five minutes. My curl script never caught it, because after an update it only checked the item, never the list.&lt;/p&gt;

&lt;p&gt;The fix was to give each kind of entry its own tag, and clear every affected tag on every write:&lt;/p&gt;

&lt;p&gt;public const string ListTag = "products:list";&lt;br&gt;
public static string ItemTag(int id) =&amp;gt; $"product:{id}";&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;// update and delete: the item and the list both contain the old values&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RemoveByTagAsync&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;ListTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;ItemTag&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="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// create: the list is stale, and an earlier GET for this id may have cached "not found"&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RemoveByTagAsync&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;ListTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;ItemTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created&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="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line also handles a sneaky case. GetOrCreateAsync will happily cache null, so calling GET /products/5 before product 5 exists caches "not found" until it expires.&lt;/p&gt;

&lt;p&gt;The habit I've picked up: for every write, list every cached view that shows the changed data, and check that each one carries a tag you clear. Then write the test from the reader's side. Update something, then read every endpoint that displays it.&lt;/p&gt;

&lt;p&gt;One more thing you'll notice with Redis as L2 is keys like &lt;strong&gt;MSFT_HCT&lt;/strong&gt;products:list sitting next to your data. That's HybridCache keeping track of tag invalidations, so don't let a clean-up script delete them.&lt;/p&gt;

&lt;p&gt;So, is it still under 5 ms?&lt;br&gt;
Yes, and this time I have numbers instead of a curl script. BenchmarkDotNet on .NET 9, with Redis 7 running in Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Served by                                   Mean
L1 hit (in-process)                       0.88 µs
L2 hit (Redis + deserialize)               288 µs
Miss (factory, 30 ms simulated database)  31.1 ms


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Over real HTTP on localhost, a cached GET /products took about 1.5 ms, compared with about 127 ms for the first, uncached call. In production you'll add your network round-trip on top. The benchmarks are in the repo if you want to run them on your own hardware.&lt;/p&gt;

&lt;p&gt;What I'd tell myself before writing the first article&lt;br&gt;
Check that each cache layer works, not just that the endpoint is fast. A fast response proves something was cached. It doesn't tell you which cache, or whether the other one is even switched on.&lt;br&gt;
Test the app the way it really runs. My component tests were green while the real setup deadlocked.&lt;br&gt;
Treat stampede protection as per-process. Across instances, assume the factory can run more than once.&lt;br&gt;
Test invalidation from the reader's side.&lt;br&gt;
The full sample is on GitHub, with the tests, the benchmarks and a CI pipeline that runs everything against Redis: &lt;a href="https://github.com/paulap887/HybridCache" rel="noopener noreferrer"&gt;paulap887/HybridCache&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you run into HybridCache surprises of your own? Tell me in the comments. I'd really like to know what I've missed.&lt;/p&gt;

&lt;p&gt;Originally published on &lt;a href="https://medium.com/gitconnected/4-hybridcache-gotchas-i-only-found-when-i-tested-my-own-sample-c896e40fc0bc" rel="noopener noreferrer"&gt;Level Up Coding&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>csharp</category>
      <category>dotnet</category>
      <category>redis</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
