HomeNikola Knezevic

In this article

Banner

304 Not Modified with Delta in ASP.NET Core

24 Sept 2026
6 min

Sponsor Newsletter

Many APIs repeatedly return the same data. Products lists, configuration payloads and reference data often change far less often than they are read.

When nothing has changed, running the full query again and shipping the same payload is wasted work. The client already has a valid copy.

HTTP already has an answer for this, 304 Not Modified with ETags. The hard part is knowing cheaply whether the database data behind the response actually changed.

That's where Delta comes in. It builds ETags from database change tracking so unchanged GETs can short-circuit with a 304.

Delta

Delta is a library by Simon Cropp that implements 304 Not Modified by leveraging DB change tracking.

It reads a last-updated timestamp from the database, turns that into an ETag and checks it on every dynamic GET. If the client's If-None-Match matches, Delta returns 304 and your endpoint body never runs the expensive work again.

This works best when updates are relatively rare compared to reads. Clients still get fresh data when something changes, while most requests stay cheap.

Delta supports SQL Server and PostgreSQL, either with raw connections or with EF Core via Delta.EF. In this post I'll focus on SQL Server with Dapper, matching the sample project.

If you care about broader caching strategies in ASP.NET Core, check out my posts on HybridCache and FusionCache.

Getting Started

To get started, install the NuGet package. You can do this via the NuGet Package Manager or by running the following command in the Package Manager Console:

bash
Install-Package Delta

You'll also need a SQL Server connection available in DI. Delta discovers SqlConnection from the request services by default:

csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped(_ => new SqlConnection(
    builder.Configuration.GetConnectionString("Database")));

You can enable Delta for the whole app with UseDelta, for a route group or for a single endpoint. In the sample I enable it only on the products endpoint that should participate in caching.

SQL Server Setup

On SQL Server, Delta prefers the transaction log when the user has VIEW SERVER STATE. Without that permission it falls back to change tracking and/or row versioning.

A simple and effective approach is to add a ROWVERSION column to the tables you care about:

sql
CREATE TABLE Products (
    Id UNIQUEIDENTIFIER PRIMARY KEY,
    CreatedAt DATETIME NOT NULL,
    ModifiedAt DATETIME NULL,
    Name NVARCHAR(255) NOT NULL,
    Description NVARCHAR(MAX) NOT NULL,
    Price DECIMAL(18, 2) NOT NULL,
    RowVersion ROWVERSION
);

SQL Server updates RowVersion automatically on every insert or update. Delta can use @@DBTS (and change tracking when enabled) to build a database-wide timestamp for the ETag.

Your entity can map that column even if you never serialize it to clients:

csharp
public sealed class Product
{
    public Guid Id { get; set; }

    public DateTime CreatedAt { get; set; }

    public DateTime? ModifiedAt { get; set; }

    public string Name { get; set; }

    public string Description { get; set; }

    public decimal Price { get; set; }

    [JsonIgnore]
    public byte[] RowVersion { get; set; }
}

Enabling Delta on Endpoints

Here's a minimal API that returns a large product list, once without Delta and once with it:

csharp
app.MapGet("/products/delta", async (SqlConnection connection) =>
{
    var query = "SELECT TOP 10000 * FROM Products ORDER BY Name";

    var products = await connection.QueryAsync<Product>(query);

    return Results.Ok(products);
}).UseDelta();

app.MapGet("/products", async (SqlConnection connection) =>
{
    var query = "SELECT TOP 10000 * FROM Products ORDER BY Name";

    var products = await connection.QueryAsync<Product>(query);

    return Results.Ok(products);
});

Calling UseDelta on the endpoint is enough. On the first request Delta adds an ETag to the response. On later requests with a matching If-None-Match, the response becomes 304 Not Modified.

You can also wire Delta at the app level:

csharp
var app = builder.Build();

app.UseDelta();

Or scope it with shouldExecute when only some paths should participate:

csharp
app.UseDelta(
    shouldExecute: httpContext =>
    {
        var path = httpContext.Request.Path.ToString();
        return path.Contains("products");
    });

For multi-tenant or per-user responses, pass a suffix callback so the ETag includes that context. If the suffix reads user claims, authentication middleware must run before UseDelta.

How ETags Work

Delta builds the ETag from a few parts:

  • Assembly write time - Invalidates caches after you deploy a new build
  • Database timestamp - Comes from SQL Server change tracking / row versioning
  • Optional suffix - Extra context such as user or tenant

Conceptually it looks like this:

csharp
"{AssemblyWriteTime}-{DbTimeStamp}-{Suffix}"

On each GET, Delta calculates the current ETag. If the request includes If-None-Match and it matches, the pipeline responds with 304. Otherwise it adds the ETag header and lets the endpoint produce the full body.

By default Delta queries the database for a fresh timestamp on every GET. Clients can send Cache-Control directives like max-age or max-stale to allow a short-lived cached timestamp and skip that round-trip.

Verifying Behavior

Delta is built primarily for browser clients. Browsers already understand ETags and 304 responses.

To verify it:

  • Open the page that calls your Delta-enabled endpoint
  • Open browser DevTools and switch to the Network tab
  • Refresh the page

Cached responses show status 304. The request should include If-None-Match and the response should include ETag.

NOTE: If "Disable cache" is checked in DevTools, the browser will not send If-None-Match and you will always get a full response.

Also watch certificates during local development. Chromium browsers can skip caching for XHR calls against self-signed certificates. Firefox is often easier for verifying 304 behavior locally.

If a .NET HttpClient is the consumer instead of a browser, plain HttpClient will not cache 304 responses for you. In that case look at client-side helpers such as Replicant. For HttpClient basics, see my post on HTTP requests with HttpClient.

Conclusion

Delta makes HTTP conditional requests practical when your source of truth is a database.

Register a SQL connection, add row versioning or change tracking and call UseDelta on the endpoints that return mostly stable data. Unchanged reads become 304s. Changed data still flows through normally.

It is a small addition with a large payoff when read traffic dominates.

If you want to check out examples I created, you can find the source code here:

Source Code

I hope you enjoyed it, subscribe and get a notification when a new blog is up!

Related posts
Caching in .NET using Fusion Cache
Caching in .NET using Fusion Cache

FusionCache is an advanced caching library that simplifies handling both in-memory (L1) and distributed (L2) caching while adding advanced features and resiliency.

New Caching in ASP.NET Core - HybridCache
New Caching in ASP.NET Core - HybridCache

With the release of .NET 9, HybridCache has been introduced, seemingly aiming to replace the older interfaces.

Cache Stampede Protection in ASP.NET Core
Cache Stampede Protection in ASP.NET Core

To mitigate cache stampedes, you have several strategies. Choosing the right approach depends on your application's scale and complexity.

Subscribe

Stay tuned for valuable insights every Thursday morning.