The Silent Data Loss Problem Every Multi-User App Faces
Multi-user applications have a dangerous default behavior that many developers don't realize until it's too late. When multiple users modify the same data simultaneously, the last person to save wins, and everyone else's changes disappear without a trace.
Silent data loss. No exceptions thrown, no error messages, no warnings - just data quietly vanishing.
The good news? EF Core has a built-in solution that requires just one attribute.
The Problem: When "Last Write Wins" Becomes "Everyone Loses"
In multi-user applications, the default behavior is deceptively dangerous. When multiple users modify the same entity simultaneously, the last person to save wins, and everyone else's changes disappear without a trace.
Here's how this common scenario unfolds:
- User A loads a product (Stock: 100, Price: $25.99, Name: "Widget")
- User B loads the same product (Stock: 100, Price: $25.99, Name: "Widget")
- User A changes the stock to 1000 (maybe bulk inventory adjustment)
- User B reduces stock to 75 via raw SQL (direct database update)
- User A saves their changes β
- Result: User A's stock value (1000) overwrites User B's stock change (75) π₯
The key insight: Data loss occurs when both users modify the same field concurrently. If User A only changed the name and price (leaving stock untouched), User B's stock change would survive. But when both operations touch the same field, the EF Core save overwrites the direct database change.
Note: This happens specifically when EF Core's SaveChanges() includes a field that was also modified by raw SQL or another concurrent operation.
The Hero: One Attribute to Rule Them All
Meet the [Timestamp]
attribute - EF Core's built-in superhero for data integrity:
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public int Stock { get; set; }
public decimal Price { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = new byte[8]; // π¦ΈββοΈ Your data guardian
}
That's it. One attribute. Eight bytes. Zero data loss.
The Magic Behind the Scenes
When you add [Timestamp]
to a property, EF Core transforms from a passive bystander into an active protector:
Without RowVersion (The Dangerous Way):
-- User B's save overwrites everything
UPDATE Products
SET Name = 'Original Widget', Stock = 50, Price = 25.99
WHERE Id = 1
With RowVersion (The Safe Way):
-- EF Core includes the version in the WHERE clause
UPDATE Products
SET Name = 'Premium Widget', Stock = 50, Price = 29.99
WHERE Id = 1 AND RowVersion = 0x00000000000007D0
If another user changed the data (updating the RowVersion), this query affects 0 rows, triggering a DbUpdateConcurrencyException
. No silent data loss. Ever.
See It In Action: The Demo That Will Change How You Think About Data
I built a working demo that shows exactly what happens with and without concurrency control. Here's what you can test:
π΄ The Dangerous Scenario
POST /demo-concurrency-with-stock-change
What happens: EF Core silently overwrites concurrent changes. Data loss occurs, and nobody knows.
π’ The Protected Scenario
POST /demo-with-rowversion
What happens: DbUpdateConcurrencyException
is thrown. The conflict is detected and must be handled explicitly.
π‘ The Edge Case
POST /demo-concurrency-no-stock-change
What happens: When EF Core doesn't modify a field, concurrent raw SQL changes survive. Interesting, but not reliable for production.
Handling Conflicts Like a Pro
When DbUpdateConcurrencyException
occurs, you have three battle-tested strategies:
1. Store Wins (Reload and Show Current Data)
catch (DbUpdateConcurrencyException)
{
await context.Entry(product).ReloadAsync();
// Show user the current database values
// Let them decide what to do
}
2. Client Wins (Force the Update)
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
entry.OriginalValues.SetValues(entry.GetDatabaseValues());
await context.SaveChangesAsync(); // Force save
}
3. Smart Merge (Best of Both Worlds)
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var currentValues = entry.CurrentValues;
var databaseValues = entry.GetDatabaseValues();
// Example: Keep user's name/price changes, preserve database stock
currentValues["Stock"] = databaseValues["Stock"];
entry.OriginalValues.SetValues(databaseValues);
await context.SaveChangesAsync();
}
The Business Case: Why This Matters
The Cost of Data Loss:
- E-commerce: Incorrect inventory leads to overselling
- Finance: Transaction amounts get corrupted
- Healthcare: Patient data becomes inconsistent
- Any Business: User trust erodes, reputation suffers
The Cost of Implementation:
- Development Time: 5 minutes to add the attribute
- Performance Impact: ~1ms per operation
- Storage Cost: 8 bytes per row
- Maintenance: Zero - it just works
ROI: Infinite. You can't put a price on data integrity.
The Developer's Concurrency Checklist
β Always Use RowVersion For:
- Multi-user applications
- Financial transactions
- Inventory management
- Any critical business data
- Long-running forms
π Implementation Best Practices:
Create a Base Entity:
public abstract class BaseEntity
{
public int Id { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = new byte[8];
}
// Now all your entities are protected
public class Product : BaseEntity
{
public string Name { get; set; } = "";
public int Stock { get; set; }
public decimal Price { get; set; }
}
Test Concurrent Scenarios:
[Fact]
public async Task Should_Detect_Concurrent_Updates()
{
// Load same entity in two contexts
var product1 = await context1.Products.FindAsync(id);
var product2 = await context2.Products.FindAsync(id);
// Modify both
product1.Name = "Version 1";
product2.Stock = 50;
// First save succeeds
await context1.SaveChangesAsync();
// Second save should throw
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(
() => context2.SaveChangesAsync());
}
The Reality Check: Performance vs. Protection
Aspect | Without RowVersion | With RowVersion |
---|---|---|
Data Loss Risk | β High | β None |
Conflict Detection | β Silent failure | β Explicit exception |
Implementation Effort | None | 1 attribute |
Performance Impact | Baseline | +8 bytes, +~1ms |
Peace of Mind | β Sleepless nights | β Sleep like a baby |
Try It Yourself
The complete demo is available on GitHub. Clone it, run it, and see the magic happen:
git clone https://github.com/abdebek/efcore-db-concurrency-demo.git
cd efcore-db-concurrency-demo
dotnet restore
dotnet run
# Test the scenarios
curl -X POST https://localhost:7112/demo-with-rowversion
The Bottom Line
In a world where data is your most valuable asset, can you afford NOT to protect it?
The [Timestamp]
attribute is:
- β Built into EF Core
- β Automatic and reliable
- β Zero maintenance
- β Battle-tested in production
- β The difference between data loss and data safety
One attribute. Zero data loss. That's the power of [Timestamp]
.
Have you experienced silent data loss in your applications? How did you solve it? Share your story in the comments below!
π Useful Resources:
π Found this helpful? Give it a clap and follow for more practical development insights!
Top comments (0)