Start Debugging

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:

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

AreaASP.NET FrameworkASP.NET Core 11Severity
Ambient contextHttpContext.CurrentIHttpContextAccessor (register with AddHttpContextAccessor)high
Context lifetimeUsable after request at timesObjectDisposedException after request endshigh
Thread safetyThread-affine requestNo thread affinity across awaithigh
Write to responseResponse.Write(s)await Response.WriteAsync(s)medium
Read form / bodyRequest.Form, Request.InputStream (sync)await Request.ReadFormAsync(), Request.Body (read once)medium
Response headers / cookiesSet anytimeSet before the response starts (or via OnStarting)medium
Physical pathsServer.MapPath("~/x")IWebHostEnvironment.ContentRootPath / WebRootPath + Path.Combinemedium
SessionSession["k"], auto-serialized, lockedHttpContext.Session.GetString/SetString, byte-based, no lockingmedium
HTML encodingServer.HtmlEncodeSystem.Net.WebUtility.HtmlEncode / HtmlEncoderlow
Request URLRequest.Url, Request.RawUrlRequest.Scheme/Host/Path/QueryString or GetDisplayUrl()low

Pre-flight checklist

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:

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:

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

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.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back