Migrate from System.Web.HttpContext to Microsoft.AspNetCore.Http.HttpContext
A practical migration from the ASP.NET Framework System.Web.HttpContext to the ASP.NET Core 11 HttpContext: HttpContext.Current, the property map, Server.MapPath, Session, and the System.Web adapters shim for incremental migrations.
The single line that breaks more ASP.NET Framework migrations than any other is HttpContext.Current. It does not exist in ASP.NET Core. There is no static ambient context to reach into from an arbitrary class, the HttpContext type is a different type in a different namespace (Microsoft.AspNetCore.Http.HttpContext, not System.Web.HttpContext), and most of the properties you relied on have moved, changed shape, or gone async. This post maps the old API to the new one for .NET 11 / ASP.NET Core 11, then shows the two real paths forward: a clean rewrite for code you control, and the official System.Web adapters when you have a pile of shared libraries that pass HttpContext around and cannot be rewritten in one pass.
For a small handler or a single controller the rewrite is an hour. For a monolith where HttpContext.Current is threaded through a business layer in a separate assembly, budget days and reach for the adapters so the libraries keep compiling against both frameworks while you migrate app by app. Nothing about the HTTP semantics changes; what changes is how you reach the request, that the lifetime is now strictly request-scoped, and that there is no thread affinity to lean on.
Why this migration is not a find-and-replace
System.Web.HttpContext and Microsoft.AspNetCore.Http.HttpContext are genuinely different objects, and the gaps are behavioral, not just cosmetic:
HttpContext.Currentis gone. ASP.NET Framework gave each request thread affinity, so a static accessor could find the right context off the current thread. ASP.NET Core makes no such guarantee, so there is nothing equivalent to read statically. You inject the context instead.- The context cannot outlive the request. In ASP.NET Core the context is recycled at the end of the request. Touching it afterwards (a captured reference in a fire-and-forget task, a cached field) throws
ObjectDisposedException. On Framework that often “worked” by accident. - No thread affinity. A single request can hop threads across
awaitpoints. Reading and writingHttpContextconcurrently is now a data race you own. - Reads and writes go async.
Response.Writebecomesawait Response.WriteAsync. Reading the form or body isawait ReadFormAsync()/ a stream read. Response headers and cookies must be set before the response starts.
Microsoft’s own HttpContext migration guide frames this as two strategies, and the choice drives everything below: complete rewrite, or System.Web adapters for an incremental move.
What breaks
| Area | ASP.NET Framework | ASP.NET Core 11 | Severity |
|---|---|---|---|
| Ambient context | HttpContext.Current | IHttpContextAccessor (register with AddHttpContextAccessor) | high |
| Context lifetime | Usable after request at times | ObjectDisposedException after request ends | high |
| Thread safety | Thread-affine request | No thread affinity across await | high |
| Write to response | Response.Write(s) | await Response.WriteAsync(s) | medium |
| Read form / body | Request.Form, Request.InputStream (sync) | await Request.ReadFormAsync(), Request.Body (read once) | medium |
| Response headers / cookies | Set anytime | Set before the response starts (or via OnStarting) | medium |
| Physical paths | Server.MapPath("~/x") | IWebHostEnvironment.ContentRootPath / WebRootPath + Path.Combine | medium |
| Session | Session["k"], auto-serialized, locked | HttpContext.Session.GetString/SetString, byte-based, no locking | medium |
| HTML encoding | Server.HtmlEncode | System.Net.WebUtility.HtmlEncode / HtmlEncoder | low |
| Request URL | Request.Url, Request.RawUrl | Request.Scheme/Host/Path/QueryString or GetDisplayUrl() | low |
Pre-flight checklist
- Install the .NET 11 SDK (
dotnet --versionreports11.x). Pin<TargetFramework>net11.0</TargetFramework>on the web project. - Inventory every
HttpContext.Currentreference.grep -rn "HttpContext.Current"across the solution is the honest scope estimate. - Inventory
Server.MapPath,Session[,Request.Url,Response.Write, andRequest.ServerVariables. These are the second-tier offenders. - Decide per assembly: rewrite to native ASP.NET Core, or keep
System.Web.HttpContextand add the adapter package. Shared libraries that must keep serving the not-yet-migrated Framework app are adapter candidates. - Have a green test suite before you touch anything. The migration is mechanical, and a passing suite is how you keep it honest.
Migration steps
Step 1: Register the accessor and stop reaching for HttpContext.Current
Replace ambient access with explicit injection. In Program.cs:
// .NET 11, ASP.NET Core 11, C# 14
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddHttpContextAccessor(); // enables IHttpContextAccessor
var app = builder.Build();
app.MapControllers();
app.Run();
A service that previously read HttpContext.Current now takes IHttpContextAccessor:
// .NET 11, ASP.NET Core 11, C# 14
public sealed class CurrentUserService(IHttpContextAccessor accessor)
{
public string? UserId =>
accessor.HttpContext?.User.FindFirst("sub")?.Value;
}
Do not cache accessor.HttpContext in a field. Read it at the point of use every time, because the field would capture a context from one request and hand it to another, or to none. Inside a controller or minimal API you already have HttpContext as a property or parameter, so prefer passing it explicitly and skip the accessor entirely.
Verify: the solution compiles with zero references to System.Web in the rewritten projects, and a request that exercises CurrentUserService returns the expected user id.
Step 2: Translate the request properties
Most Request members moved rather than vanished. The mapping that covers the common cases:
// .NET 11, ASP.NET Core 11, C# 14
string method = httpContext.Request.Method; // was HttpMethod
bool isHttps = httpContext.Request.IsHttps; // was IsSecureConnection
string? remoteIp = httpContext.Connection.RemoteIpAddress?.ToString(); // was UserHostAddress
string userAgent = httpContext.Request.Headers.UserAgent.ToString();
// Query string: IQueryCollection, indexer never throws on a missing key
string q = httpContext.Request.Query["key"].ToString(); // "" if absent
// Full URL: no single Request.Url anymore
// using Microsoft.AspNetCore.Http.Extensions;
string url = httpContext.Request.GetDisplayUrl();
Reading the form or body is async and the body is a forward-only stream you can read once:
// .NET 11, ASP.NET Core 11, C# 14
if (httpContext.Request.HasFormContentType)
{
IFormCollection form = await httpContext.Request.ReadFormAsync();
string firstName = form["firstname"].ToString();
}
Verify: hit an endpoint that reads query, form, and a header; assert the values match what the Framework app returned for the same request.
Step 3: Translate the response, and respect when headers can be set
Writing is async, and headers and cookies must be set before the body starts flowing:
// .NET 11, ASP.NET Core 11, C# 14
httpContext.Response.StatusCode = StatusCodes.Status200OK;
httpContext.Response.ContentType = "application/json";
httpContext.Response.Headers["X-Custom"] = "value"; // before first write
await httpContext.Response.WriteAsync(payload);
If you are in middleware and need to set headers right before the response is sent, use the callback instead of setting them late:
// .NET 11, ASP.NET Core 11, C# 14
httpContext.Response.OnStarting(static state =>
{
var ctx = (HttpContext)state;
ctx.Response.Headers["X-Late"] = "value";
return Task.CompletedTask;
}, httpContext);
Verify: inspect the response headers with curl -i; confirm the header is present and you get no response has already started exception under load.
Step 4: Replace Server.MapPath with IWebHostEnvironment
Server.MapPath("~/App_Data/x.json") has no equivalent. Inject IWebHostEnvironment and combine paths yourself:
// .NET 11, ASP.NET Core 11, C# 14
public sealed class FileService(IWebHostEnvironment env)
{
public string DataPath(string name) =>
Path.Combine(env.ContentRootPath, "App_Data", name); // project root
public string AssetPath(string name) =>
Path.Combine(env.WebRootPath, name); // wwwroot
}
ContentRootPath is the project root (the old ~/), WebRootPath is wwwroot (the old static-file root). For HTML encoding, Server.HtmlEncode becomes System.Net.WebUtility.HtmlEncode or, in DI, an injected HtmlEncoder.
Verify: a request that loads a file resolves the same absolute path you expect, on both Windows and Linux (the Path.Combine keeps it portable).
Step 5: Move Session, knowing it behaves differently
ASP.NET Core session is opt-in, byte-based, not automatically serialized, and offers no per-request locking. Register it:
// .NET 11, ASP.NET Core 11, C# 14
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession();
// ...
app.UseSession(); // before endpoints
Then swap the indexer for the typed helpers:
// .NET 11, ASP.NET Core 11, C# 14
httpContext.Session.SetString("user", "marius"); // was Session["user"] = "marius"
string? user = httpContext.Session.GetString("user");
httpContext.Session.SetInt32("count", 3);
Storing an object means serializing it yourself (for example with System.Text.Json) and calling SetString. There is no automatic object session like Framework had. The session migration guide is worth a read if you depended on session locking.
Verify: set a value on one request, read it back on the next; confirm it survives across requests with the same session cookie.
When a rewrite is too big: the System.Web adapters
If HttpContext is woven through class libraries that a not-yet-migrated Framework app also calls, rewriting every signature at once is not viable. Microsoft ships the System.Web adapters for exactly this. They re-implement the shape of System.Web.HttpContext on top of the ASP.NET Core context, so a library can target netstandard2.0 and serve both runtimes.
The packages you will see:
Microsoft.AspNetCore.SystemWebAdapters: the shim itself, referenced by shared libraries. Targets .NET Standard 2.0, .NET Framework 4.5+, and .NET 5+.Microsoft.AspNetCore.SystemWebAdapters.CoreServices: referenced by the ASP.NET Core app to configure behavior. Targets .NET 6+.Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices: referenced by the Framework app during incremental migration.
In the ASP.NET Core app you opt in:
// .NET 11, ASP.NET Core 11, C# 14
builder.Services.AddSystemWebAdapters();
// ...
app.UseSystemWebAdapters();
A library that took System.Web.HttpContext keeps compiling after you swap the System.Web reference for the adapter package. To convert between the two representations within a request you use the cached conversions, which lets you rewrite targeted call sites incrementally:
// .NET 11, ASP.NET Core 11, C# 14
// Microsoft.AspNetCore.Http.HttpContext -> System.Web.HttpContext
System.Web.HttpContext legacy = coreContext.AsSystemWeb();
// System.Web.HttpContext -> Microsoft.AspNetCore.Http.HttpContext
HttpContext core = legacy.AsAspNetCore();
The adapters are not free. They add overhead versus native APIs, not every member is supported, and two behaviors need opting into because ASP.NET Core does not provide them by default: a seekable, fully-buffered request stream (PreBufferRequestStream) and a buffered response (BufferResponseStream). If a library reads the body twice or relies on Response.End(), enable those on the relevant endpoints:
// .NET 11, ASP.NET Core 11, C# 14
app.MapDefaultControllerRoute()
.PreBufferRequestStream()
.BufferResponseStream();
Verification
After the migration, run through this list:
dotnet buildreports zero warnings aboutSystem.Webin projects you rewrote.dotnet testpasses with no skipped HTTP-context tests.- A smoke test of the hot paths: login (claims via
HttpContext.User), a form POST, a file download, a session round-trip. - Load test briefly and watch for
ObjectDisposedExceptionorresponse has already started. Those two exceptions are the signature of a captured-context bug or a late header write.
Rollback
This is a code migration, not a data migration, so rollback is a git revert of the branch. The one thing to watch is session state format: ASP.NET Core session is not wire-compatible with ASP.NET Framework session, so if you flipped production traffic and users have live sessions, a rollback drops those sessions and forces a re-login. Drain or accept that. Nothing else here is one-way.
Gotchas worth knowing before you start
- Captured
HttpContextin background work. The most common production failure: a controller kicks offTask.Run(() => DoWork(HttpContext))and the context is disposed by the timeDoWorkreads it. Copy what you need into a plain object first. This is the same disposed-context trap that bites EF Core’sDbContextin fire-and-forget code. accessor.HttpContextis null off-request. In a hosted service or a startup task there is no request, so the accessor returns null. That is correct, not a bug. Background services have their own scoped-service pattern.- Reading the body twice.
Request.Bodyis forward-only. If model binding already consumed it, a later read gets nothing. UseEnableBuffering()or the adapters’PreBufferRequestStream. Synchronous reads also throw unless you allow them, which is the same root cause behind the synchronous operations are disallowed exception. - DI registration order. If a service that needs
IHttpContextAccessorcannot resolve it, you forgotAddHttpContextAccessor(), which surfaces as the familiar unable to resolve service for type error.
If you are doing this as part of a broader framework move, this fits inside the larger .NET Framework 4.8 to .NET 11 migration, and you will likely also be replacing the hosting model in the same pass when you migrate from IWebHostBuilder to WebApplication.CreateBuilder. For new endpoints written during the migration, the minimal APIs versus controllers trade-offs are worth weighing before you port the old controller shape verbatim.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.