Every ASP.NET developer eventually meets Session early in their career and treats it like a safe, per-screen storage box. It feels local. It feels tied to whatever page you're looking at. That assumption works fine, right up until a user opens two tabs of the same application and an "impossible" bug starts appearing in production.
This article covers a real case of this, how Session actually identifies a user, how to reproduce the bug yourself in five minutes, and how the possible fixes compare.
- The Scenario
Picture an HR or payroll-style application where a logged-in user views different employee records across multiple tabs:
Tab 1 has Employee A open, who qualifies for Transaction Type 1
Tab 2 has Employee B open, who qualifies for Transaction Type 2
The application stores context whenever a screen loads:
csharp
Session["CurrentEmployeeId"] = employeeId;
Session["EligibleTransactionType"] = eligibility
Opening Tab 2 overwrites both values. Tab 1 still shows Employee A on screen, but the server now believes the user is working on Employee B. When the user returns to Tab 1 and clicks Save, the save logic reads Employee B's eligibility and applies it to Employee A's record.
Nothing crashes and nothing is logged. The data is simply wrong.
Step What the user sees What Session holds
- Open Tab 1 Employee A Employee A, Type 1
- Open Tab 2 Employee B Employee B, Type 2
- Back to Tab 1, click Save Employee A Employee B, Type 2
Step 3 is the bug: the screen and the server disagree, and the save trusts the server.
- How Session Actually Identifies a User
Session is not tied to a tab or a page. It is tied to a cookie.
On the first request, ASP.NET creates a session on the server and sends the browser a cookie named ASP.NET_SessionId.
On every later request, the browser sends that cookie back.
ASP.NET uses the cookie value to find the matching session object in the session store (in-process memory, a state server, or SQL Server, depending on configuration).
Cookies are shared by every tab in the same browser profile, so every tab sends the same session ID and reaches the same server-side object. That is the entire root cause.
One practical consequence: two separate browsers, or a normal window and an incognito window, get separate sessions. If you test this bug in incognito, you will not see it. Use two normal tabs in the same browser.
- Reproduce It Yourself in Five Minutes
A minimal Web Forms page is enough:
csharp
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
string id = Request.QueryString["id"];
Session["CurrentEmployeeId"] = id;
lblScreen.Text = "This screen shows employee " + id;
}
}
protected void btnSave_Click(object sender, EventArgs e)
{
lblResult.Text = "Saved against employee " + Session["CurrentEmployeeId"];
}
Steps:
Open Employee.aspx?id=1 in Tab 1.
Open Employee.aspx?id=2 in Tab 2, in the same browser window.
Go back to Tab 1 and click Save.
Tab 1 still says "This screen shows employee 1", but the result says it saved against employee 2. A postback doesn't reset the session value, so the last page load wins.
- Why Locking Doesn't Save You
A common objection is that ASP.NET protects session data from concurrent access. It does, but that protection solves a different problem.
In classic ASP.NET (System.Web), requests that share a session ID are serialized when the page can write to session state. One request holds the lock until it finishes, and the next waits. Microsoft documents this behavior, and a page declared with EnableSessionState="ReadOnly" avoids taking the lock. You can see it directly by adding Thread.Sleep(10000) to a page, loading it in Tab 1, and then loading another page of the same application in Tab 2. If the lock applies, Tab 2 waits.
The important point is what this does and doesn't prevent. The lock stops two requests from corrupting a session object at the same instant. It does nothing about the bug in this article, because that bug isn't two requests colliding. It is a stale value written earlier and read later. Each request runs safely, one after the other, and the second simply reads what the first left behind.
ASP.NET Core behaves differently. Its session state is documented as non-locking: if two requests modify the session at the same time, the last one wins. So in Core the same scenario can also produce a true race, on top of the stale-value problem. If your application runs on Core or is migrating to it, treat that as an additional reason not to trust session values at save time. Check your own version and configuration, since I'm describing documented behavior, not a measurement from your system.
- Fixes Compared Approach What it fixes Cost Re-validate against the database at save time Wrong business rule applied One extra read per save Carry the employee ID in the request (hidden field, route, query string) Each tab becomes self-contained Every screen has to be changed Tab-scoped Session keys Keeps using Session, one entry per tab Cleanup and memory growth Stop storing this context in Session Removes the root cause Largest refactor
Re-validate at the point of write is the minimum, because it protects the data even when everything else fails:
csharp
// Fragile: trusts whatever is in Session
if (Session["EligibleTransactionType"].ToString() == requestedType) { Save(); }
// Safer: re-checks the record actually being saved
var currentEligibility = _employeeService.GetEligibility(employeeIdFromForm);
if (currentEligibility == requestedType) { Save(); }
Tab-scoped keys are the option for teams that can't remove Session quickly. Each page load generates its own ID and stores its context under it:
csharp
if (!IsPostBack)
{
string tabId = Guid.NewGuid().ToString("N");
ViewState["TabId"] = tabId; // belongs to this page instance
Session["Ctx:" + tabId] = new EmployeeContext
{
EmployeeId = id,
EligibleType = eligibilityType
};
}
// On Save
string tabId = (string)ViewState["TabId"];
var ctx = (EmployeeContext)Session["Ctx:" + tabId];
Each tab's ViewState carries its own tabId, so each tab reads its own entry. The trade-offs are real: entries pile up and need to be cleaned up when a screen is left, and this only fixes the overwrite problem. It does not replace validating on save.
- Why Single-Tab Testing Never Finds It
This bug survives normal QA for structural reasons:
Test scripts follow one linear flow in one tab
The wrong output is plausible, not obviously broken, so it passes casual review
It is often blamed on user error or "random data"
By the time anyone notices, real records may already be affected
A useful addition to any test plan for a multi-screen workflow is one explicit case: open two records in two tabs, switch back, and save.
- Questions This Topic Raises in Interviews
Is Session tied to a browser tab? No. It is tied to the ASP.NET_SessionId cookie, which all tabs in the same browser profile share.
ASP.NET locks session access. Why does the bug still happen? The lock prevents simultaneous access, not stale data. Two requests can run safely one after the other while the second reads a value the first left behind.
How would you fix it? Validate against the database at save time, and carry context in the request or in tab-scoped state instead of relying on one shared Session slot.
Takeaway
Session is a convenience cache for one user, shared by every tab that user has open. Treating it as the source of truth for what a particular screen is doing is the mistake. The data stays safe when each save re-checks the record it is actually writing, and a two-tab test case is the cheapest way to find the problem before users do.

Top comments (0)