An ETag (short for Entity Tag) is an HTTP header used for cache validation and conditional requests.
It’s essentially a unique identifier (a token or hash) assigned by a server to a specific version of a resource. We can think of the ETag as a version fingerprint of our resource — usually generated from:
RowVersion/timestamp column in our database.| Reason | Explanation |
|---|---|
| 1. Bandwidth Optimization | Prevents re-downloading unchanged data — the server can return 304 Not Modified instead of the full payload. |
| 2. Faster Client Performance | Cached resources load instantly when they haven’t changed. |
| 3. Reduced Server Load | Less data transfer and processing since unchanged resources don’t need to be regenerated or resent. |
| 4. Reliable Change Detection | More precise than Last-Modified headers (which can miss millisecond changes or false positives due to clock differences). |
| 5. Safer Concurrency Control | On updates (PUT/POST), ETags can prevent overwriting newer data — ensuring the resource hasn’t changed since the client last fetched it. |
| 6. Works for Both Static & Dynamic Content | Can be based on file hashes, database row versions, or generated values for API responses. |
| Scenario | Should We Use ETag? | Reason |
|---|---|---|
| Static files (images, CSS, JS) | ✅ Yes | Helps browsers avoid redundant downloads when content hasn’t changed. |
| RESTful APIs | ✅ Yes | Great for validating cached responses and preventing stale updates. |
| Sensitive data or private endpoints | ⚠️ With care | ETags can leak version info — avoid exposing predictable identifiers. |
| Frequently changing data | ⚠️ Maybe | Only if we can generate ETags efficiently (e.g., via database rowversion). |
When a client (like a web browser) requests a resource: GET /image.png HTTP/1.1 Host: example.com
The server might respond with: HTTP/1.1 200 OK ETag: "abc123" Content-Type: image/png Content-Length: 12345
Here, "abc123" is the ETag — it represents the version of /image.png.
Next time the client requests the same resource, it includes the ETag it has in the If-None-Match header: GET /image.png HTTP/1.1 Host: example.com If-None-Match: "abc123"
The server compares the provided ETag ("abc123") with the current ETag of /image.png on the server:
HTTP/1.1 304 Not Modified and no body (saving bandwidth). No body, only headers — faster and lighter.HTTP/1.1 200 OK ETag: "def456" Content-Type: image/png (with the new version of the resource and a new ETag).There are two forms:
ETag: "abc123"ETag: W/"abc123" The W/ prefix means “weak”.A simple way to describe the difference between these two types is – from MDN
Weak validators are easy to generate but are far less useful for comparisons. Strong validators are ideal for comparisons but can be very difficult to generate efficiently.
For more detailed explanations we can read this https://www.rfc-editor.org/rfc/rfc9110#name-weak-versus-strong from the RFC 9110 semantics.
Let’s look at a simple examples of how to set up ETag in the database, the server side (Model and controller ) codes and finally making an api call from the client side.
In an SQL database, rowversion or timestamp is used for the ETag column data type (can be and is also used in Non-relational DBs as well). ROWVERSION data type in SQL Server serves the purpose of providing an automatically incrementing, unique binary number within a database. This is an 8-byte value which is updated every time the row containing the rowversion column is modified or inserted. We can create a table using the following script
CREATE TABLE Products (
Id INT PRIMARY KEY IDENTITY,
Name NVARCHAR(100),
Price DECIMAL(10,2),
RowVersion ROWVERSION
);
There are two ways we can set up the model class for the Product table.
[Timestamp] (recommended)EF Core provides some easy to use features for ETag columns. In short the benefits are
rowversion or timestamp in SQL Server.And the code will look like this –
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = default!;
public decimal Price { get; set; }
// SQL Server timestamp or rowversion (automatically updated)
[Timestamp]
public byte[] RowVersion { get; set; } = default!;
}
Note the the [Timestamp] attribute above the RowVersion property with the byte[] datatype, this tells EF Core to automatically update this when the row changes. In C# – to compare this byte array ETag from two object we can use the SequenceEqual method from the Enumerable class.
var isEqual = productA.RowVersion.SequenceEqual(productB.RowVersion);
or
var isEqual = Enumerable.SequenceEqual(productA.RowVersion, productB.RowVersion);
If we want to manually control ETag generation:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = default!;
public decimal Price { get; set; }
public string ETag { get; set; } = default!;
}
product.ETag = Guid.NewGuid().ToString("N"); // new ETag on update
await _context.SaveChangesAsync();
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly AppDbContext _context;
public ProductsController(AppDbContext context)
{
_context = context;
}
[HttpGet("{id}")]
public async Task GetProduct(int id)
{
var product = await _context.Products.FindAsync(id);
if (product == null)
return NotFound();
// Convert RowVersion to base64 string for ETag
var etag = Convert.ToBase64String(product.RowVersion);
// Check If-None-Match header
var clientEtag = Request.Headers["If-None-Match"].ToString();
if (clientEtag == $"\"{etag}\"")
{
// Resource not modified
return StatusCode(StatusCodes.Status304NotModified);
}
// Resource was modified
// Return resource with ETag
Response.Headers["ETag"] = $"\"{etag}\"";
return Ok(product);
}
}
✅ Explanation:
ETag header.If-None-Match value, we compare and skip re-sending the content.Initial request: GET /api/products/1
Response: 200 OK ETag: "AAAAAAAAB9M=" Content-Type: application/json
Subsequent request: GET /api/products/1 If-None-Match: "AAAAAAAAB9M="
If unchanged: 304 Not Modified
NOTE: If-None-Match is and http header that makes the api call conditional. This is used to check if the resources have been modified. For more details have e a look at this page from MDN
Until now, we have been talking about how great ETag is. But no system or concept in software engineering is PERFECT. Neither is ETag. There are some drawbacks and we have to be cautious about them when using ETags.
| Category | Issue | Explanation |
|---|---|---|
| 🧮 Computation Cost | ETag generation overhead | Generating ETags (especially if based on hashing large responses or serialized JSON) can increase CPU load on the server. For dynamic data, it may require reading from the DB or serializing the object just to compute a hash. |
| 🕒 Frequent Invalidations | Too sensitive to small changes | If ETag is based on file timestamps, DB row versions, or even metadata, it can change frequently — invalidating the cache even when the content hasn’t meaningfully changed. |
| ⚙️ Inconsistent Across Servers | Problem in load-balanced or CDN environments | If multiple servers generate ETags differently (e.g., include machine-specific metadata), clients may get mismatched ETags for the same resource. This can break caching in distributed setups unless ETag generation is consistent across all nodes. |
| 🔐 Potential Information Leakage | Predictable or detailed ETags can expose internal state | ETags that encode database rowversions, timestamps, or hashes may reveal implementation details or change patterns (use opaque/random values to mitigate this). |
| 🔁 Complexity in Updates (Concurrency Control) | Requires careful coordination | When using ETags for optimistic concurrency (e.g., conditional PUT with If-Match), we need extra logic to handle mismatches, versioning, and 412 (Precondition Failed) responses correctly. |
| 🌐 Limited CDN Compatibility | Some CDNs ignore or mis-handle weak ETags | Weak ETags (W/"...") may be ignored by CDNs or proxies that don’t fully support RFC 9110 semantics ( describes the overall architecture of HTTP). Strong ETags might be stripped by intermediaries unless configured properly. |
| 💾 Storage or DB Overhead | If storing ETags manually | If we keep ETags in the database (instead of using built-in timestamps/rowversions), it adds a column and maintenance logic to every update. |
| 🧩 Redundant with Other Caching Headers | Can overlap with Last-Modified and Cache-Control | If not configured properly, ETags can conflict or add no extra value over simpler caching strategies. |
Imagine three servers behind a load balancer:
ETag: "abc123"ETag: "def456"If a client gets "abc123" from A and then hits B next time, B thinks it’s outdated — even if the content is identical. Fix: Make sure all servers generate ETags deterministically (e.g., based on a DB version, not server metadata).
This blog describes the basics of the ETag header on HTTP and how to use it in the codes. In a later blog we might look into a different use case of the Etag – where we can leverage it to improve the collaboration between users in an web app.