KennyG
OMVPPaaS CMS Developer Certification
Oct 2, 2026
visibility 39
star star star star star
(0 votes)

So you've decided to move to .NET 10 (but stay on CMS 12)

Ok, "decided" is a strong word. Microsoft decided for us. .NET 8 and .NET 9 both reach end of support on November 10, 2026, and if you're on CMS 12 and Commerce 14, the calendar is the project plan whether you like it or not.

CMS 13 is the shiny option, but it brings Opti ID, Optimizely Graph in place of Search & Navigation, and (for Commerce) a real rewrite. That's a project. Staying on CMS 12 / Commerce 14 and moving the runtime to .NET 10 is the "buy yourself time" option. Optimizely has said both CMS 12 and Commerce 14 support it.

We've been working through this for a couple of months on a DXP-hosted CMS 12 + Commerce 14 site. We're not on Production yet, so this isn't a victory lap. It's the list I wish someone had handed me on day one.

Isn't this just a TargetFramework change?

No. The TFM change is the easy part. The packages decide the rest.

  • The .NET 10-compatible Commerce 14 requires a newer EPiServer.Find.Commerce, which requires Search & Navigation (Find) 17. The build fails outright until you take it. It's not a warning you can sit on.
  • Find 17 changes the index format, which means a full reindex in every environment. More on that below, because it's the biggest item on this list.
  • Take the latest patch of everything, not the first version that compiles. We hit a SQL deadlock on bulk publish with Find 17.0.1. It went away after we moved to 17.0.2 together with the latest CMS and Commerce patches. We didn't isolate which one fixed it.
  • Check the official System requirements page against the blog posts. For a while they disagreed. Know which one you're relying on.

Why is my homepage a 500 right after deploying?

Against an index built by Find 16, Find 17 queries come back with a null content link, and you get an ArgumentNullException deep in ContentInstanceCache. If anything in your header or layout does a Find query, that's every page.

Things to plan for:

  • Rollback is asymmetric. Redeploying the old code is easy. But Find 16 code can't read a Find 17-format index either, so a rollback needs a second full reindex. Plan to fix forward.
  • The out-of-the-box indexing job may not be your whole index. If you push catalog content into Find with your own jobs, list every type you index and make sure each one actually has a job that indexes it. We found types that no job covered at all.
  • HTTP 200 is not "healthy." A half-built index can return {"total":0} in 30 ms and pass every smoke test. Check what the page actually renders (nav item counts, search result counts), not just status codes.
  • Big reindex jobs need to batch. One-item-at-a-time indexing of ~100k variants took about 12 hours. Batching brought it to about an hour. (The out-of-memory errors we saw on that job were the memory limit in the next section, not the lack of batching.)
  • For environments with live traffic, ask about a temporary second index. Optimizely told us it can provision a temporary migration index, so you build the new index next to the live one and flip DefaultIndex at cutover. Be clear that you mean a temporary one, not a permanent one. On DXP that flip is a support change request, not something you can do yourself on Preproduction/Production. At the time of writing ours hasn't been provisioned yet, so ask early.
  • Staging slots share the database. If you think of reindexing in a slot, remember the new code can run schema upgrades against the same database the live slot is using. Optimizely's suggestion was to turn off automatic schema updates on the slot (UpdateDatabaseSchema = false).

Why is it suddenly out of memory?

This one cost us the most time, so I'll spend a minute on it.

Same code, same environment tier. On .NET 8 our Integration site happily grew past 8 GB. On .NET 10 the same site capped at about 6 GB and threw OutOfMemoryException on heavy reports, exports and reindex jobs.

The reason: since .NET 9, the runtime honors the memory limit of the parent cgroup the App Service site runs in, and .NET 8 did not (dotnet/runtime#93611, part of #93030). By default the GC heap gets 75% of that limit. So .NET 8 was quietly using memory the newer runtime won't touch. If you're already on .NET 9, you already live with this limit.

What to do before you deploy:

  • Measure your current peak Private Bytes on .NET 8, per environment, for 30 days. Compare it with what the GC will allow on .NET 10: about 75% of the site's memory limit, which is lower than the plan's RAM. On an 8 GB P1v3 the site limit was about 7 GB.
  • Log what the runtime actually thinks it has at startup. Don't guess. This one line showed us the real limit:
var gcInfo = GC.GetGCMemoryInfo();
logger.LogInformation("GC TotalAvailableMemoryBytes={TotalAvailable}", gcInfo.TotalAvailableMemoryBytes);
  • HeapHardLimitPercent buys some headroom, but it's a stopgap. The rest of the memory is native memory you still need:
<ItemGroup>
  <RuntimeHostConfigurationOption Include="System.GC.HeapHardLimitPercent" Value="80" />
</ItemGroup>
  • Know about DATAS if you're comparing memory between .NET 8 and .NET 10. Since .NET 9, Server GC turns on dynamic heap sizing (DATAS) by default. We turned it off (<GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>) so our numbers compared like for like.
  • Ask Optimizely about sizing early if your numbers say you need a bigger tier. Bring the measurements: the startup log line and the runtime issue link.

Wait, did we just upgrade Preproduction?

Good news: you don't need to ask DXP to change anything. It picks the base image from the target framework in your package, per environment. So Integration can run .NET 10 while Preproduction and Production stay on .NET 8.

The trap: a promotion reuses the source environment's container image, runtime included. Promote your .NET 10 Integration build and Preproduction is now on .NET 10. While you're evaluating, deploy the package directly to Integration and keep sending .NET 8 packages downstream.

To revert an environment, redeploy a .NET 8 package. One catch: Commerce and CMS package updates upgrade the database schema on first boot, and older packages refuse to start against a newer schema. A .NET 8 package is a clean rollback only if it carries the same Optimizely package versions. The .NET 10-compatible Commerce 14 still targets .NET 8, so ship the package updates on .NET 8 first. Then the runtime is the only thing you'd be rolling back.

Why does it build on my machine and not in CI?

  • System.Linq.Async vs. the BCL. .NET 10 added AsyncEnumerable to the framework, and the old package still arrives transitively through the CMS UI packages. Exclude its compile assets in every project that sees it (Microsoft's documented fix):
<PackageReference Include="System.Linq.Async" Version="6.0.1">
  <ExcludeAssets>compile</ExcludeAssets>
</PackageReference>
  • Castle.Core must stay below 5.0 for CMS 12. An add-on whose .NET 10 dependency group pulls in Castle.Core 5.x breaks boot with a TypeLoadException. In test projects, newer Moq versions pull it in too. The add-on author we reported it to fixed it within a day.
  • The classic NuGet task can disagree with dotnet restore. With the nuget.exe version our pipeline pinned, restore failed on an NU1605 downgrade that dotnet restore on the .NET 10 SDK never reported. Use a nuget.exe that matches your SDK, or switch CI to dotnet restore, and run a CI build early.
  • Build tools that target an older runtime (code generators, CLI tools invoked from MSBuild) fail on an agent that only has .NET 10. DOTNET_ROLL_FORWARD=LatestMajor in the pipeline fixed it for us.
  • Pin LangVersion if your team needs to keep a C# version ceiling. The new SDK will happily let people use features you didn't agree to.
  • Third-party UI packages with per-version license keys or hard-coded CDN versions break quietly when the package version moves and the hard-coded number doesn't.

Why does EF Core suddenly throw a syntax error?

If you move EF Core up with the runtime (EF Core 8 and 9 end support on the same date as .NET 8), check your large .Contains() calls. EF Core 10 sends a list as separate parameters by default, but past SQL Server's 2,100-parameter limit it falls back to one JSON parameter decoded with OPENJSON. That needs SQL Server compatibility level 130 or higher. Our local copy of the CMS database (restored from a DXP export) was at 110, and a .Contains() over about 5,000 IDs threw Incorrect syntax near '$'. Small lists worked fine, so a quick test won't catch it.

Check yours:

SELECT name, compatibility_level FROM sys.databases;

If you can't raise the level, tell EF to inline constants for that context:

options.UseSqlServer(connectionString,
    sql => sql.UseParameterizedCollectionMode(ParameterTranslationMode.Constant));

This applies to every .Contains() in that context, so expect more distinct query plans in SQL Server's plan cache.

Is that a .NET 10 bug or something else?

Most of what our first regression pass found wasn't .NET 10 at all. Before you blame the runtime, check:

  • Concurrency bugs that were already there. We had Task.WhenAll over several content loads on one request, which shares a database connection context that isn't built for parallel use. It was latent on .NET 8 and showed up on the new stack. Search for parallel IContentLoader calls.
  • Stale front-end bundles in local builds. If dist/ is gitignored, a local dotnet publish copies whatever bundles happen to be on disk. Renders fine, looks wrong.
  • Lower-environment content drift. Integration content and settings drift from Production. Compare with Preproduction before you file a bug: if it also fails on the .NET 8 environment, it's not a regression.
  • Old release pipelines. A pipeline nobody has touched in a while may point at a retired hosted agent image.

How do I test this without breaking things?

  • Turn off outbound integrations before you test. A regression pass that runs jobs and publishes content will send records to every partner, CRM and ticketing endpoint that environment is configured for. Check which of those are production endpoints before you start.
  • Script it. Our first pass was a full page-by-page walk-through and took two days. Most of it found content drift. The rerun is a script of about 90 minutes: boot log, a critical-path test suite, a page/API status sweep, heavy reports and jobs under load, one end-to-end checkout in test mode, an exception diff.
  • Capture before-and-after numbers in the same time window: request percentiles, memory, exceptions by type, job durations. If you change the tier and the runtime at the same time, you'll want to know which one helped.
  • Do a database copy-down rehearsal on Integration. Booting a Production-sized database on the new stack catches things a stale lower environment won't. A copy-down brings Production's scheduled jobs and settings with it, so turn off outbound jobs before the site boots.

So what did I miss?

That's the list so far. We're still working toward the Preproduction and Production cutover, so I expect to add to it. If you've already done this move on DXP, especially the Find 17 cutover on a live site, I'd love to hear how you sequenced it. Let me know in the comments.

Oct 02, 2026

Comments

error Please login to comment.
Latest blogs
From robots.txt to GEO Analytics: A Year On

In September 2025, I wrote about updating

Adnan Zameer | Oct 2, 2026 |

Connect Your AI Agents Directly to Optimizely CMS

Connect any AI agent to Optimizely CMS and Commerce via Epicweb Agent API, reusing CMS-aware tools, instructions, and permissions to automate SEO,...

Luc Gosso (MVP) | Oct 1, 2026 |

Why Your CMS 12 and CMS 13 Sites Can't Share a Graph Instance

If you're planning your Optimizely CMS 13

Adnan Zameer | Oct 1, 2026 |