System.Web on .NET 8 or 10: what breaks and what replaces it

ASP.NET MVC 5, Web API 2 and the class libraries behind them lean on System.Web: HttpContext.Current, Session, HttpRuntime.Cache, Forms Authentication, modules, handlers, Web.config. None of that ships with .NET 8 or .NET 10. This guide covers what each piece becomes in ASP.NET Core, what the System.Web adapters can carry over, and an order for the work.

What breaks

System.Web.dll belongs to .NET Framework, and ASP.NET Core does not reference it. Microsoft’s migration overview puts “the pervasive use of HttpContext and associated types throughout a code base” first on its list of what makes the move hard, and its instructions for a shared library start with “Remove reference to System.Web in the project file”. A class library that keeps that reference does not build for net8.0 or net10.0.

For a Web API 2 project, Microsoft names three things that “don’t exist in ASP.NET Core”: the ApiController class, the System.Web.Http namespace and the IHttpActionResult interface. For MVC 5 and everything around it: “Modules, Global.asax.cs, Web.config (except for IIS configuration) and the application life cycle are gone”, and the same goes for handlers. The compiler finds these for you. The harder part is the code that compiles after a rename and then behaves differently.

System.Web to ASP.NET Core

System.WebASP.NET CoreWhat changes
HttpContext.CurrentIHttpContextAccessor, registered with AddHttpContextAccessor() and injectedThere is no static current context. Store the accessor and read HttpContext at the moment of use; a context kept in a field may belong to a request that is already over.
HttpContext.ItemsHttpContext.ItemsSame idea, typed IDictionary<object, object>. Still per request.
Session["key"]HttpContext.Session (ISession) after AddSession() and UseSession()Values are byte[]; strings and integers have helpers, anything else you serialize yourself. No session locking, and keys are case sensitive. The store is an IDistributedCache.
HttpRuntime.Cache, HttpContext.CacheIMemoryCache from Microsoft.Extensions.Caching.MemoryMicrosoft also offers System.Runtime.Caching/MemoryCache “as a compatibility bridge when porting code from ASP.NET 4.x”.
Server.MapPath, HostingEnvironment.MapPathIWebHostEnvironmentWebRootPath is the wwwroot folder, ContentRootPath the application folder. Build the path with Path.Combine.
Forms AuthenticationCookie authenticationMicrosoft: “No direct equivalent, requires migration to cookie authentication”.
MembershipASP.NET Core IdentityUsers and roles can be copied with a script. Passwords and salts do not migrate; Microsoft recommends leaving the password empty and asking users to reset it.
IHttpModuleMiddlewareOrder follows the order of app.Use… calls, and responses pass through middleware in reverse. No BeginRequest/EndRequest events.
IHttpHandlerMiddleware at the end of a pipeline branchapp.MapWhen(ctx => ctx.Request.Path.ToString().EndsWith(".report"), branch => branch.UseMyHandler()) replaces the <handlers> entry.
Web.configappsettings.json and IConfigurationThe web.config that a published ASP.NET Core app carries only configures IIS.
Global.asaxProgram.csStartup logic, routes and middleware are code in one file.
System.Web.Optimization bundlesA front-end tool“ASP.NET Core doesn’t provide a native bundling and minification solution.” Microsoft points to Gulp and Webpack, with the <environment> tag helper to switch files per environment.
ApiController, IHttpActionResultControllerBase, ActionResult<T>[ApiController] makes attribute routing mandatory; [Route("api/[controller]")] and [HttpGet("{id}")] replace MapHttpAttributeRoutes and the route table.

The System.Web adapters

Microsoft maintains the adapters in dotnet/systemweb-adapters ↗ for “developers who have taken a reliance on System.Web types within their class libraries”. The package Microsoft.AspNetCore.SystemWebAdapters is “a subset of the APIs from System.Web.dll backed by Microsoft.AspNetCore.Http types”. A library that references it compiles for .NET Standard 2.0, .NET Framework and .NET 5 and later at the same time, so the same assembly works for the old application and the new one while both run.

The web application adds Microsoft.AspNetCore.SystemWebAdapters.CoreServices and registers the services and the middleware:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSystemWebAdapters();

var app = builder.Build();

app.UseSystemWebAdapters();

app.Run();

Inside the request, System.Web.HttpContext.Current works again, and an ASP.NET Core HttpContext converts to the System.Web type by implicit cast or AsSystemWeb(). With Microsoft.AspNetCore.SystemWebAdapters.FrameworkServices on the old side, the two applications can also share state while you move routes one at a time:

  • Remote session: the ASP.NET Core app reads and writes the session of the Framework app over HTTP (AddRemoteAppClient and AddSessionClient on one side, AddRemoteAppServer and AddSessionServer on the other). Every session key has to be registered with a type for serialization; a missing registration is an error.
  • Remote authentication: the ASP.NET Core app forwards the Cookie and Authorization headers to the Framework app and gets a serialized ClaimsPrincipal back (AddAuthenticationClient). Sign-in and sign-out stay in the old app, Windows authentication is not supported, and every authenticated request costs an extra HTTP call.
  • Modules and Global.asax: AddHttpApplication<Global>() runs an existing HttpApplication subclass, and options.RegisterModule<MyModule>("MyModule") adds an IHttpModule; instances come from a pool instead of being created for each request.

The incremental setup Microsoft describes puts a new ASP.NET Core app in front of the old one and proxies everything it does not handle yet to the Framework app with YARP. Routes move one by one.

What the adapters do not do: the README says “If a member is not found, it is not currently supported on ASP.NET Core”, and Microsoft’s own list of considerations includes “Not all HttpContext features are available through adapters”. The context cannot be used after the request ends (ObjectDisposedException). There is no thread affinity, so HttpContext.Current is available in the same async context but not on an arbitrary thread. Response.End(), Response.Clear() and Response.Output only work on endpoints that opt into response buffering (.BufferResponseStream() on the route, or endpoint metadata), and a seekable Request.InputStream needs the PreBufferRequestStream attribute. Microsoft also lists a native rewrite as the “long-term strategy” for best performance.

An order that works

Microsoft’s guidance for the incremental route is to clean up first (package updates, SDK-style project files, PackageReference, compiler warnings), then upgrade libraries “in a postorder depth-first search ordering”: leaves first, the web project last. Only the libraries behind the routes you are moving need to change at that point.

For each library: target .NET Standard 2.0 if you can. If the library needs APIs outside the standard, multi-target instead and keep the Framework target until the last Framework caller is gone:

<TargetFrameworks>net48;net8.0</TargetFrameworks>

The SDK defines NET48 and NET8_0 for #if blocks around the few places that differ. Microsoft recommends .NET Framework 4.7.2 or later for projects that consume .NET Standard 2.0 libraries. Where a library reaches into HttpContext, either put that access behind an interface the web project implements, or add the System.Web adapters package and keep the code.

Tooling: Microsoft marks the .NET Upgrade Assistant as “officially deprecated” and points to the GitHub Copilot upgrade agent, which runs in Visual Studio, Visual Studio Code, the Copilot CLI and GitHub.com. It assesses the solution, writes assessment.md, plan.md and tasks.md under .github/upgrades/, proposes a bottom-up, top-down or all-at-once strategy, and commits each step so you can review or roll back. Among its scenarios are the Newtonsoft.Json to System.Text.Json swap and the SqlClient upgrade.

Where the time goes

HttpContext.Current in the data layer

Static helpers that read HttpContext.Current deep in a library are the usual blocker. The ASP.NET Core answer is to inject IHttpContextAccessor and read HttpContext at the moment of use. Microsoft’s best practices add two rules: do not store IHttpContextAccessor.HttpContext in a field, because the constructor often runs with a null or wrong context, and do not touch HttpContext from a background thread; copy the values you need first. Code that only wants the user name or a header is easier to fix by passing those values as parameters.

ConfigurationManager.AppSettings

The NuGet package System.Configuration.ConfigurationManager brings the ConfigurationManager class to .NET, and the API reference lists .NET 10 for it. ASP.NET Core itself does not read web.config; the file a published app contains is there for IIS. We did not find a documented way to point ConfigurationManager at web.config in an ASP.NET Core app. The documented route is appsettings.json, environment variables and the options pattern through IConfiguration.

machineKey, Forms Authentication tickets and MachineKey.Protect

ASP.NET Core protects cookies and tokens with its data protection system, and the keys live in a key ring (PersistKeysToFileSystem, Redis, Azure Blob Storage) under an application name set with SetApplicationName. Microsoft’s documented bridge runs the other way: the package Microsoft.AspNetCore.DataProtection.SystemWeb makes a .NET Framework 4.5.1+ application route forms authentication, view state and MachineKey.Protect through data protection, with a <machineKey compatibilityMode="Framework45" dataProtectorType="…" /> entry in web.config. Once both apps share the application name and the key ring, protected payloads round-trip between them, and you can recognize them by the CfDJ8 prefix. We did not find a documented way for ASP.NET Core to read a ticket protected by an old <machineKey> value, so plan the key switch before the first route moves, or accept one forced sign-in.

System.Drawing.Common on Linux

From .NET 6 the System.Drawing.Common package is Windows only. On Linux or macOS the first call throws a TypeInitializationException with PlatformNotSupportedException inside, and the System.Drawing.EnableUnixSupport switch that existed in .NET 6 was removed in .NET 7. Microsoft names SkiaSharp, ImageSharp, Aspose.Drawing and Microsoft.Maui.Graphics as replacements. Microsoft saw the package used cross-platform mostly for image manipulation, such as QR code generators and text rendering, and those are the places to look in an old MVC app.

Data providers

System.Data.SqlClient still works, but Microsoft says “Use Microsoft.Data.SqlClient for new development” and that types with the same name from the two packages are not interchangeable. Encryption defaults changed along the way (Encrypt=true since version 4.0), so a connection string that worked against an old SQL Server may stop. System.Data.OracleClient is documented as the .NET Framework data provider for Oracle and marked deprecated there; plan on another provider.

Synchronous I/O

Kestrel’s AllowSynchronousIO controls whether synchronous reads and writes on the request and response body are allowed, and Microsoft says to enable it only “when using a library that doesn’t support asynchronous I/O”. Request.Form reads the body sync over async; use ReadFormAsync(). new StreamReader(Request.Body).ReadToEnd() in an old handler becomes ReadToEndAsync(), and a Json.NET serializer that only reads synchronously needs the body buffered into memory first.

Web API 2 controllers and JSON

A Web API 2 controller becomes an ASP.NET Core controller by changing the base class to ControllerBase, replacing IHttpActionResult with ActionResult<T> or IActionResult, and adding [ApiController], [Route] and [HttpGet]-style attributes. GlobalConfiguration.Configure(WebApiConfig.Register) and config.Routes.MapHttpRoute have no counterpart; app.MapControllers() does the job.

JSON changes as well, and the compiler does not report it. ASP.NET Core serializes with System.Text.Json by default, and Microsoft notes that some features do not work well with it. If your models carry [JsonProperty] or [JsonIgnore] from Newtonsoft.Json, or your clients depend on its output, install Microsoft.AspNetCore.Mvc.NewtonsoftJson and call .AddNewtonsoftJson() after AddControllers(). Microsoft lists exactly these cases as reasons to keep Json.NET.

Culture

ASP.NET Framework set CultureInfo.CurrentCulture for each request; ASP.NET Core does not. Dates and numbers formatted in views or in ToString() calls change until you add app.UseRequestLocalization().

If the list above is longer than the time you have, this is work we do as the migration service: we take the solution, do the ordering and the rewrites, and hand back projects that build and run on .NET 8 or 10.

Not sure how much of System.Web your code touches?

Send us the list of projects in the solution, or a few sentences about the application, and we answer by email with what blocks the move to .NET 8 or 10 and what we would do about it. The check is free and does not commit you to anything.

If you want us to make the change, we agree a fixed price for an agreed scope by email before any work starts.

  • What does the application do and who uses it?
  • Which .NET Framework version and project types (ASP.NET MVC, Web Forms, WCF, Windows service, WinForms/WPF)?
  • Which third-party packages or components does it depend on?
  • What is the goal: .NET 8/10 on Windows, Linux, containers, cloud?

How to measure the scope yourself

The .NET Portability Analyzer (ApiPort) is deprecated, its backend service is shut down, and the Visual Studio extension does not support Visual Studio 2022 or later. The .NET Upgrade Assistant that replaced it is deprecated as well in favor of the Copilot upgrade agent, whose assessment stage produces the list of breaking changes and incompatible APIs per project.

A text search gives a first number in a minute. From the root of the repository:

rg -l "System.Web" --glob "*.cs" --glob "*.csproj" --glob "*.config"

rg -l prints the files that mention System.Web, and the --glob filters limit it to C# files, project files and config files. Group the output by project: a library with no hits is ready to retarget; a library with hits in a handful of files is a candidate for an interface or the adapters; a project where most files hit is the web application itself, which goes last. Since the adapters keep the System.Web namespace, the count does not drop when you add them, so compare the numbers before you do.

Sources

More guides