17 min left
0% complete
Building Idempotent User Actions: A Scalable Backend Pattern for Rapid Interactions
Have you ever double-tapped a like button—only to find your action reversed due to a laggy network? Or seen a favorite get lost because two rapid clicks sent conflicting requests?
Have you ever double-tapped a like button—only to find your action reversed due to a laggy network? Or seen a favorite get lost because two rapid clicks sent conflicting requests?
Modern web and mobile apps demand instant responsiveness. But when users tap faster than the network can respond, backend systems often struggle with race conditions, stale updates, and inconsistent state. In this guide, you'll learn how to build a robust, version-aware backend that safely handles rapid user interactions—no matter the network conditions.
We’ll walk through a production-tested pattern for idempotent user actions (like, dislike, bookmark) that ensures only the latest user intent is preserved, even when requests arrive out of order. You’ll learn how to eliminate race conditions, prevent data corruption, and scale confidently under high concurrency—all with clean, maintainable code.
Let’s dive in.
How It Works: Version-Aware User Actions
At its core, this system adds version tracking to user actions. Each interaction carries a unique actionId—typically a timestamp—and the backend checks whether it’s processing the most recent intent.
This enables three critical guarantees:
- Idempotency: Replayed requests (e.g., from network retry) are safely ignored.
- Stale rejection: Out-of-order requests are rejected if a newer action has already been processed.
- Concurrency safety: Simultaneous requests are resolved without database errors.
| Responsibility | Outcome |
|---|---|
| Stale rejection | actionId ≤ lastActionId → ignore request |
| Idempotency | Duplicate actionId → return current state |
| Concurrency safety | Retry logic handles simultaneous updates |
| Lightweight operations | Minimal data loaded, no entity tracking |
The result? A smooth user experience where likes, bookmarks, and dislikes are always consistent—regardless of how fast or how many times users tap.
Architecture Overview
Here’s how the pieces fit together:
FRONTEND REQUEST
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ GraphQL Mutation │
│ │
│ toggleLikeItem(itemId: "item-456", actionId: 1740000000042) │
│ │
│ 1. Validate user is authenticated │
│ 2. Verify item exists (lightweight: only fetch OwnedByUserId) │
│ 3. Delegate to UserActionService.ToggleUserActionWithVersionAsync() │
└─────────────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ UserActionService │
│ │
│ 1. Call VersionStore.TryAdvanceActionIdAsync() │
│ ├─ If stale (actionId <= stored): return current state, NO toggle │
│ └─ If newer (actionId > stored): update stored, proceed to toggle │
│ │
│ 2. Toggle the action: │
│ ├─ If action exists: DELETE it (user is un-liking) │
│ └─ If action missing: INSERT it (user is liking) │
│ │
│ 3. Return { isActive, lastActionId } │
└─────────────────────────────────┬───────────────────────────────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ UserEntityActionVersions │ │ UserActions │
│ (version tracking) │ │ (actual like/dislike) │
│ │ │ │
│ PK: (UserId, EntityType, │ │ UserId, EntityType, │
│ EntityId) │ │ EntityId, ActionType │
│ LastActionId: bigint │ │ │
└───────────────────────────┘ └───────────────────────────┘Two tables work in tandem:
UserEntityActionVersionstracks the latestactionIdper(user, entity)pair.UserActionsstores the actual state (liked, bookmarked, etc.).
This separation allows fast, safe version checks without loading full entity graphs.
The Problems We Solved
1. Entity Framework Tracking Conflicts
Early versions of the system loaded full entity models in mutation resolvers, like this:
// BAD — loads Item with Category, Brand, Seller, etc.
var item = await _itemService.GetByIdWithIncludesAsync(itemId);When rapid requests hit the same DbContext, EF Core's change tracker threw errors like:
System.InvalidOperationException: The instance of entity type 'Category'
cannot be tracked because another instance with the same key value for {'Id'}
is already being tracked.Solution: Use lightweight projections that fetch only necessary data:
// GOOD — single column projection, no tracking
var ownedByUserId = await _itemService.GetOwnerUserIdAsync(itemId);This avoids loading unnecessary navigation properties and eliminates tracking conflicts.
2. Race Conditions from Rapid Clicks
Without versioning, rapid clicks could flip-flop state:
User clicks: Like → Unlike → Like
Network delivers: Like → Like → Unlike
Final state: UNLIKED (wrong!)Even if the user's last action was “like,” an earlier request arriving late could undo it.
Solution: Use actionId to enforce that only the latest intent matters:
With version control:
Request 1 (actionId=1000) → stored=0 → PROCESS
Request 2 (actionId=1001) → stored=1000 → PROCESS
Request 3 (actionId=1002) → stored=1001 → PROCESS
Request 2 re-sent → stored=1002 → REJECT (stale)
Final state: LIKED (correct)3. Concurrency Exceptions
Simultaneous requests to update the same version row caused DbUpdateConcurrencyException:
DbUpdateConcurrencyException: The database operation was expected to affect
1 row(s), but actually affected 0 row(s).Two requests read the same LastActionId, then both try to update it—only one succeeds.
Solution: Implement retry logic with ChangeTracker.Clear() to ensure fresh reads (more on this later).
The Version Store: Core of Idempotency
The key component is the version store, which atomically checks and advances the actionId.
Interface
// Interfaces/IUserEntityActionVersionStore.cs
public interface IUserEntityActionVersionStore
{
/// <summary>
/// Atomically checks if actionId is newer than stored, and updates if so.
/// Returns (shouldProcess, currentLastActionId):
/// - shouldProcess=true: This request should be processed
/// - shouldProcess=false: This request is stale, return current state
/// </summary>
Task<(bool ShouldProcess, long CurrentLastActionId)> TryAdvanceActionIdAsync(
string userId, string entityType, string entityId, long actionId);
}This method ensures:
- Older or duplicate
actionIdvalues are rejected. - Only newer values trigger state changes.
- Concurrent access is handled safely.
Implementation with Retry Logic
// Persistence/Repositories/UserEntityActionVersionStore.cs
public class UserEntityActionVersionStore : IUserEntityActionVersionStore
{
private readonly AppDbContext _context;
private const int MaxRetries = 3;
public UserEntityActionVersionStore(AppDbContext context)
{
_context = context;
}
public async Task<(bool ShouldProcess, long CurrentLastActionId)> TryAdvanceActionIdAsync(
string userId, string entityType, string entityId, long actionId)
{
for (int attempt = 0; attempt < MaxRetries; attempt++)
{
try
{
return await TryAdvanceActionIdCoreAsync(userId, entityType, entityId, actionId);
}
catch (DbUpdateConcurrencyException)
{
_context.ChangeTracker.Clear();
if (attempt == MaxRetries - 1) throw;
}
catch (DbUpdateException ex) when (IsDuplicateKeyException(ex))
{
_context.ChangeTracker.Clear();
if (attempt == MaxRetries - 1) throw;
}
}
throw new InvalidOperationException("TryAdvanceActionIdAsync exceeded max retries");
}
private async Task<(bool ShouldProcess, long CurrentLastActionId)> TryAdvanceActionIdCoreAsync(
string userId, string entityType, string entityId, long actionId)
{
var version = await _context.UserEntityActionVersions
.FirstOrDefaultAsync(v =>
v.UserId == userId &&
v.EntityType == entityType &&
v.EntityId == entityId);
if (version == null)
{
// First action for this user+entity — create the row
version = new UserEntityActionVersion
{
UserId = userId,
EntityType = entityType,
EntityId = entityId,
LastActionId = actionId
};
_context.UserEntityActionVersions.Add(version);
await _context.SaveChangesAsync();
return (true, actionId);
}
// Reject stale requests
if (actionId <= version.LastActionId)
{
return (false, version.LastActionId);
}
// Advance to newer actionId
version.LastActionId = actionId;
await _context.SaveChangesAsync();
return (true, actionId);
}
private static bool IsDuplicateKeyException(DbUpdateException ex)
{
return ex.InnerException is SqlException sqlEx &&
(sqlEx.Number == 2627 || sqlEx.Number == 2601);
}
}Key Behaviors
| Scenario | actionId vs LastActionId | Result |
|---|---|---|
| First action ever | No row exists | Create row, return (true, actionId) |
| Newer action | actionId > LastActionId | Update row, return (true, actionId) |
| Stale action | actionId <= LastActionId | No update, return (false, LastActionId) |
| Duplicate (retry) | actionId == LastActionId | No update, return (false, LastActionId) |
GraphQL Schema Design
We expose toggle mutations for different actions and entities.
Mutations
type Mutation {
# Items
toggleLikeItem(itemId: String!, actionId: Long!): ToggleLikeResult!
toggleDislikeItem(itemId: String!, actionId: Long!): ToggleDislikeResult!
toggleBookmarkItem(itemId: String!, actionId: Long!): ToggleBookmarkResult!
# Coupons
toggleLikeCoupon(couponId: String!, actionId: Long!): ToggleLikeResult!
toggleDislikeCoupon(couponId: String!, actionId: Long!): ToggleDislikeResult!
}Result Types
type ToggleLikeResult {
isLiked: Boolean!
lastActionId: Long!
}
type ToggleDislikeResult {
isDisliked: Boolean!
lastActionId: Long!
}
type ToggleBookmarkResult {
isBookmarked: Boolean!
lastActionId: Long!
}Why actionId Is Required
The actionId is non-nullable (Long!) for important reasons:
- Forces consistency: Ensures every request goes through version validation.
- Prevents misuse: No risk of forgetting to send
actionId. - Simplifies logic: No branching for legacy vs new clients.
✅ Best Practice: UseDate.now()in the frontend to generate always-increasingactionIdvalues.
You can support backward compatibility by making actionId optional with a default, but it’s not recommended:
long actionId = 0 // fallback if not providedRequest Lifecycle: From Mutation to Database
Here’s the full flow of a toggleLikeItem request:
1. GRAPHQL REQUEST ARRIVES
└─ toggleLikeItem(itemId: "item-456", actionId: 1740000000042)
2. AUTHENTICATION CHECK
└─ RequireUserId(httpContextAccessor)
├─ Success: userId = "user-123"
└─ Failure: throw GraphQL error (USER_NOT_AUTHENTICATED)
3. ITEM VALIDATION (lightweight)
└─ GetOwnerUserIdAsync(itemId)
└─ SELECT OwnedByUserId FROM Items WHERE Id = @itemId
├─ Found: proceed
└─ Not found: throw (ITEM_NOT_FOUND)
4. VERSION CHECK (atomic)
└─ TryAdvanceActionIdAsync(userId, "Item", itemId, actionId)
└─ SELECT * FROM UserEntityActionVersions
WHERE UserId=@userId AND EntityType='Item' AND EntityId=@itemId
Case A: No row → INSERT, return (true, 1740000000042)
Case B: Row exists, LastActionId=1740000000040 → UPDATE, return (true, 1740000000042)
Case C: Row exists, LastActionId=1740000000050 → STALE, return (false, 1740000000050)
5. STALE CHECK
└─ If shouldProcess == false:
└─ Query current action state (isCurrentlyLiked?)
└─ Return { isLiked: <current>, lastActionId: 1740000000050 }
└─ DONE — no toggle performed
6. TOGGLE THE ACTION
└─ Does UserAction exist for (userId, "Item", itemId, LikeItem)?
Case A: Exists → DELETE → isActiveAfterToggle = false
Case B: Missing → INSERT → isActiveAfterToggle = true
7. RETURN RESULT
└─ { isLiked: isActiveAfterToggle, lastActionId: 1740000000042 }This flow ensures correctness, safety, and minimal database load.
Handling Concurrency Safely
The Problem
Two simultaneous requests can both read the same LastActionId, then try to update it:
Request A (actionId=1000) ──┐
├──► Both read LastActionId = 0
Request B (actionId=1001) ──┘
A: 1000 > 0 → UPDATE → SUCCESS
B: 1001 > 0 → UPDATE → CONFLICT! (row already changed)The Solution: Retry Loop
We wrap the update logic in a retry loop:
for (int attempt = 0; attempt < MaxRetries; attempt++)
{
try
{
return await TryAdvanceActionIdCoreAsync(...);
}
catch (DbUpdateConcurrencyException)
{
_context.ChangeTracker.Clear();
if (attempt == MaxRetries - 1) throw;
}
}Why ChangeTracker.Clear() Is Crucial
Without clearing the change tracker, EF Core returns the same stale entity from memory—even after a database update.
// Second attempt WITHOUT Clear():
var version = await _context.UserEntityActionVersions.FirstOrDefaultAsync(...);
// EF returns tracked entity with LastActionId = 0 (stale!)
// We try to update again → same conflictWith ChangeTracker.Clear():
_context.ChangeTracker.Clear();
var version = await _context.UserEntityActionVersions.FirstOrDefaultAsync(...);
// EF fetches fresh from DB: LastActionId = 1000
// Now compare: 1001 > 1000 → proceedThis ensures each retry sees the latest database state.
Entity Framework Optimizations
Use Lightweight Projections
Avoid full entity loads. Instead, project only needed fields:
// BAD — loads Item with all navigation properties
var item = await _context.Items
.Include(i => i.Category)
.Include(i => i.Brand)
.FirstOrDefaultAsync(i => i.Id == itemId);
var ownerId = item?.OwnedByUserId;// GOOD — single column, no tracking
public Task<string> GetOwnerUserIdAsync(string itemId)
{
return _repository.GetPropertyAsync<Item, string>(itemId, i => i.OwnedByUserId);
}Generic Helper
public async Task<TValue> GetPropertyAsync<TEntity, TValue>(
string id,
Expression<Func<TEntity, TValue>> selector) where TEntity : class
{
return await _context.Set<TEntity>()
.AsNoTracking()
.Where(e => e.Id == id)
.Select(selector)
.FirstOrDefaultAsync();
}Use AsNoTracking() Wisely
- ✅ Use when reading for comparison (stale check):
`csharp
.AsNoTracking()
.FirstOrDefaultAsync(...)
`
- ❌ Don’t use when updating—you need EF to track changes:
`csharp
// For updates, skip AsNoTracking
var version = await _context.UserEntityActionVersions
.FirstOrDefaultAsync(...);
version.LastActionId = actionId;
await _context.SaveChangesAsync();
`
API Contract for Frontend
Request Format
mutation ToggleLikeItem($itemId: String!, $actionId: Long!) {
toggleLikeItem(itemId: $itemId, actionId: $actionId) {
isLiked
lastActionId
}
}Variables:
{
"itemId": "item-456",
"actionId": 1740000000042
}Response Format
Always returns HTTP 200, even on rejection:
{
"data": {
"toggleLikeItem": {
"isLiked": true,
"lastActionId": 1740000000042
}
}
}Detecting Stale Rejection
Compare the returned lastActionId with what was sent:
if (response.lastActionId === actionIdSent) {
// Request was processed
} else {
// STALE REJECTION
// Sync UI to returned isLiked value
}Example:
- Sent:
actionId = 1740000000042 - Received:
lastActionId = 1740000000050 - → Stale! Server already processed a newer action.
Error Responses
| Error | GraphQL Code | When |
|---|---|---|
| Not authenticated | USER_NOT_AUTHENTICATED | No valid auth token |
| Item not found | ITEM_NOT_FOUND | Invalid itemId |
| Coupon not found | COUPON_NOT_FOUND | Invalid couponId |
{
"data": { "toggleLikeItem": null },
"errors": [{
"message": "Item not found",
"extensions": {
"code": "ITEM_NOT_FOUND"
}
}]
}Scope of actionId
actionId is scoped per (userId, entityType, entityId):
(user-123, Item, item-456)→ one counter(user-123, Item, item-789)→ another counter(user-123, Coupon, coupon-001)→ another
✅ Use `Date.now()` — timestamps are globally unique and strictly increasing.
❌ Avoid sequential counters — they reset on app restart and cause stale rejections.
Persistence
LastActionId is stored in the database—it survives restarts and deployments. Frontends must use durable, increasing values (like timestamps).
Database Schema
UserEntityActionVersions Table
Tracks the latest actionId per user-entity pair.
CREATE TABLE UserEntityActionVersions (
UserId nvarchar(128) NOT NULL,
EntityType nvarchar(128) NOT NULL,
EntityId nvarchar(128) NOT NULL,
LastActionId bigint NOT NULL DEFAULT 0,
CONSTRAINT PK_UserEntityActionVersions
PRIMARY KEY (UserId, EntityType, EntityId)
);Entity Configuration (EF Core)
// In AppDbContext.OnModelCreating()
builder.Entity<UserEntityActionVersion>()
.HasKey(v => new { v.UserId, v.EntityType, v.EntityId });
builder.Entity<UserEntityActionVersion>()
.Property(v => v.LastActionId)
.HasDefaultValue(0L);Dependency Injection Setup
// In Program.cs or Startup.cs
services.AddScoped<IUserEntityActionVersionStore, UserEntityActionVersionStore>();Frequently Asked Questions
Q: What happens if the frontend sends actionId = 0?
It will be rejected unless it’s the first action ever for that user-entity pair. Always use Date.now().
Q: Can two users have the same actionId?
Yes. The version is scoped per (userId, entityType, entityId), so collisions don’t matter.
Q: What if the UserEntityActionVersions table is deleted?
All requests will initially be processed (no stored version). The table will repopulate on first write. No data loss in the UserActions table.
Q: Why not use database transactions?
Transactions work but add overhead. Retry logic is simpler and efficient for rare contention.
Q: Why Long instead of String (UUID)?
- Timestamps (
Date.now()) are numbers - Direct comparison:
>and<= - Smaller storage (8 bytes vs 36 for UUID)
- Natural ordering
Testing Your Implementation
Unit Test Scenarios
| Scenario | Input | Expected |
|---|---|---|
| First action | No row, actionId=1000 | (true, 1000), row created |
| Newer action | LastActionId=1000, actionId=1001 | (true, 1001), updated |
| Stale action | LastActionId=1000, actionId=999 | (false, 1000), no change |
| Duplicate | LastActionId=1000, actionId=1000 | (false, 1000), no change |
| Concurrent | Two simultaneous | One retries, both resolve safely |
Integration Test Scenarios
| Scenario | Steps | Expected |
|---|---|---|
| Like persists | Like → restart → query | isLiked = true |
| Stale rejected | Send 5 → send 3 → check | Second returns current state |
| Rapid clicks | 10 requests, increasing actionIds | Final state = latest |
| Cross-user isolation | User A likes → User B queries | User B sees false |
Load Test Goals
| Scenario | Target |
|---|---|
| 100 concurrent same item | All succeed, final state correct |
| 100 concurrent different items | All succeed independently |
| 1000 req/sec | <100ms p99 latency, no deadlocks |
Key Takeaways
- ✅ Use versioning to make user actions idempotent and safe.
- ✅ Track the latest intent with
LastActionId. - ✅ Reject stale requests when
actionId ≤ stored. - ✅ Use retry loops to handle concurrency.
- ✅ Call `ChangeTracker.Clear()` after concurrency exceptions.
- ✅ Use `Date.now()` as
actionIdfor always-increasing values. - ✅ Keep queries lightweight—no full entity loads.
With this pattern, your backend will handle rapid user interactions with confidence—no matter how fast users tap. Implement it once, scale it forever.