0

17 min left

0% complete

Blog

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?

·17 min read

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:

  1. Idempotency: Replayed requests (e.g., from network retry) are safely ignored.
  2. Stale rejection: Out-of-order requests are rejected if a newer action has already been processed.
  3. Concurrency safety: Simultaneous requests are resolved without database errors.
ResponsibilityOutcome
Stale rejectionactionId ≤ lastActionId → ignore request
IdempotencyDuplicate actionId → return current state
Concurrency safetyRetry logic handles simultaneous updates
Lightweight operationsMinimal 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:

  • UserEntityActionVersions tracks the latest actionId per (user, entity) pair.
  • UserActions stores 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 actionId values 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

ScenarioactionId vs LastActionIdResult
First action everNo row existsCreate row, return (true, actionId)
Newer actionactionId > LastActionIdUpdate row, return (true, actionId)
Stale actionactionId <= LastActionIdNo update, return (false, LastActionId)
Duplicate (retry)actionId == LastActionIdNo 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:

  1. Forces consistency: Ensures every request goes through version validation.
  2. Prevents misuse: No risk of forgetting to send actionId.
  3. Simplifies logic: No branching for legacy vs new clients.
Best Practice: Use Date.now() in the frontend to generate always-increasing actionId values.

You can support backward compatibility by making actionId optional with a default, but it’s not recommended:

long actionId = 0 // fallback if not provided

Request 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 conflict

With ChangeTracker.Clear():

_context.ChangeTracker.Clear();
var version = await _context.UserEntityActionVersions.FirstOrDefaultAsync(...);
// EF fetches fresh from DB: LastActionId = 1000
// Now compare: 1001 > 1000 → proceed

This 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

ErrorGraphQL CodeWhen
Not authenticatedUSER_NOT_AUTHENTICATEDNo valid auth token
Item not foundITEM_NOT_FOUNDInvalid itemId
Coupon not foundCOUPON_NOT_FOUNDInvalid 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

ScenarioInputExpected
First actionNo row, actionId=1000(true, 1000), row created
Newer actionLastActionId=1000, actionId=1001(true, 1001), updated
Stale actionLastActionId=1000, actionId=999(false, 1000), no change
DuplicateLastActionId=1000, actionId=1000(false, 1000), no change
ConcurrentTwo simultaneousOne retries, both resolve safely

Integration Test Scenarios

ScenarioStepsExpected
Like persistsLike → restart → queryisLiked = true
Stale rejectedSend 5 → send 3 → checkSecond returns current state
Rapid clicks10 requests, increasing actionIdsFinal state = latest
Cross-user isolationUser A likes → User B queriesUser B sees false

Load Test Goals

ScenarioTarget
100 concurrent same itemAll succeed, final state correct
100 concurrent different itemsAll 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 actionId for 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.

Contents