Building WebApps  «Prev  Next»


Lesson 5 Application variables and the race condition
Objective Explain race conditions in classic ASP and show modern, safe counters in ASP.NET (Core) with code.

Application Counters Without Race Conditions (ASP.NET) + Classic ASP History

This page was originally a workflow page that linked out to several non-workflow articles. Those links have been commented out and the non-workflow content has been embedded inline. We keep the legacy classic ASP sequence (with images) as a historical artifact, but the solution section below uses ASP.NET Core patterns that avoid race conditions in single-server and multi-server deployments.


Modern View: Why race conditions happen and how to fix them in ASP.NET

A race condition occurs when two or more requests attempt to read-modify-write the same shared value concurrently (e.g., a “current users online” counter). The sequence “read → +1 → write” is not atomic; parallel requests can overwrite each other and produce a wrong total.

Guiding principles

Option A (single instance/dev): Atomic increment via Interlocked + IMemoryCache

For a single Kestrel process (e.g., development or a tiny site), keep the counter in memory and update it atomically:


// Startup: builder.Services.AddMemoryCache();

// Counter service
public sealed class LocalCounter
{
    private int _count;
    public int Increment() => Interlocked.Increment(ref _count);
    public int Decrement() => Interlocked.Decrement(ref _count);
    public int Value => Volatile.Read(ref _count);
}

// Register: builder.Services.AddSingleton<LocalCounter>();

Option B (production, multi-instance): Database row with atomic UPDATE

Store the counter in a table and update it with a single SQL statement—this is atomic at the DB engine:



// Table
// CREATE TABLE SiteStats (Id int PRIMARY KEY, CurrentUsers int NOT NULL);
// INSERT INTO SiteStats(Id, CurrentUsers) VALUES (1, 0);

// EF Core service
public sealed class StatsService(AppDbContext db)
{
    public async Task<int> IncrementAsync(CancellationToken ct = default)
    {
        await db.Database.ExecuteSqlRawAsync(
            "UPDATE SiteStats SET CurrentUsers = CurrentUsers + 1 WHERE Id = 1;", ct);
        return await db.SiteStats.Where(s => s.Id == 1)
                                 .Select(s => s.CurrentUsers)
                                 .SingleAsync(ct);
    }

    public async Task<int> DecrementAsync(CancellationToken ct = default)
    {
        await db.Database.ExecuteSqlRawAsync(
            "UPDATE SiteStats SET CurrentUsers = CASE WHEN CurrentUsers > 0 THEN CurrentUsers - 1 ELSE 0 END WHERE Id = 1;", ct);
        return await db.SiteStats.Where(s => s.Id == 1)
                                 .Select(s => s.CurrentUsers)
                                 .SingleAsync(ct);
    }
}

Option C (production, multi-instance): Redis INCR/DECR

Use a distributed cache like Redis for true atomic increments across a farm:


// package: StackExchange.Redis

public sealed class RedisCounter(IConnectionMultiplexer mux)
{
    private readonly IDatabase _db = mux.GetDatabase();
    private const string Key = "stats:current-users";

    public async Task<long> IncrementAsync() => await _db.StringIncrementAsync(Key);
    public async Task<long> DecrementAsync() => await _db.StringDecrementAsync(Key);
    public async Task<long> GetAsync() => (long)(await _db.StringGetAsync(Key));
}

Wiring increments/decrements

Classic ASP had Session_OnStart/Session_OnEnd. ASP.NET Core does not raise identical lifecycle hooks, but you can approximate:


For simple session-based tracking with cookie sessions:


// Startup:
builder.Services.AddSession();

// Middleware:
app.Use(async (ctx, next) =>
{
    ctx.Session.LoadAsync().Wait();
    if (ctx.Session.GetString("counted") != "1")
    {
        var counter = ctx.RequestServices.GetRequiredService<RedisCounter>();
        await counter.IncrementAsync();
        ctx.Session.SetString("counted", "1");
    }
    await next();
});

Decrementing carefully: sessions can expire without a clean “end.” Consider a periodic reconciliation job that recomputes “currently online” by last-seen timestamps, or accept eventual accuracy rather than exactness.


Historical Artifact: Classic ASP race condition (for reference)

In classic ASP, developers often stored a “current users” counter in Application("NumCurrUsers"). Without locking, concurrent requests could overwrite each other, producing a lower total than expected. The canonical fix used Application.Lock/Application.Unlock—safe only within a single IIS process.

  1. A shared application variable tracks the number of users.
  2. Alice starts a session and reads the current value (5).
  3. Before Alice writes back, Betty starts and also reads 5.
  4. Alice writes 6.
  5. Betty writes 6 (overwriting Alice’s update).
  6. Result: two arrivals but total increased by only 1.
1) Our website is using an application variable to track the current number of users accessing the site at any time.
1) Our website is using an application variable to track the current number of users accessing the site at any time. This variable is increased or decreased in value as part of the Session_OnStart and Session_OnEnd procedures. Currently there are five users.
2) New user Alice arrives at the site, and a new session is created for her.
2) Alice arrives, Session_OnStart reads NumCurrUsers = 5 and plans to write 6.
3) However, before the procedure can update the counter variable, user Betty arrives at the site.
3) Before Alice writes, Betty arrives and also reads 5.
4) Alice's Session_OnStart procedure completes itself by adding 1 to the value copied from the variable (5), and stores the sum
4) Alice writes back 6.
5) Then Betty's Session_OnStart procedure completes itself by adding 1 to the value copied from the variable
5) Betty writes back 6 (overwriting Alice’s increment).
6) We started with 5 users and added 2 more users, but our NumCurrUsers variable is showing a total of 6 users.
6) Two arrivals, but the total shows 6 instead of 7 — a classic race condition.

Classic ASP fix (single process only)

Locking the Application object serializes updates but does not scale across multiple worker processes or servers:


<%
Application.Lock
Application("NumCurrUsers") = Application("NumCurrUsers") + 1
Application.Unlock
%>

Quick decision guide


SEMrush Software