Meet The Author

I'm Ethan Jackson, An 25 years old blogger Currently living in London, United Kingdom. I'm a Skilled Blogger, Part Time web Developer And Creating new things as a web Designer.

author

.NET MAUI CoreCLR Migration: Startup and Memory Benchmarks

Leave a Comment
Mono has historically been utilized as the runtime for mobile platforms in.NET MAUI apps.

With.NET 11, that drastically changes.

The only runtime available for.NET MAUI mobile apps aimed at Mac Catalyst, iOS, and Android as of .NET 11 Preview 6 is CoreCLR. The CoreCLR runtime, which powers desktop programs, cloud services, ASP.NET Core, and other.NET workloads, is being leveraged by Microsoft for mobile workloads.

This goes beyond a simple implementation detail.

 

Changing the runtime can affect:

  • Application startup

  • Memory behavior

  • Package size

  • JIT and AOT behavior

  • Diagnostics

  • Debugging

  • Reflection-heavy libraries

  • Third-party dependencies

  • Platform integrations

Microsoft's own guidance is clear that developers should measure their applications rather than assume that every application will improve equally. The .NET MAUI team specifically recommends comparing startup and package size against a .NET 10 baseline using real devices and Release builds.

This makes CoreCLR migration an excellent opportunity for a controlled benchmark.

What Changed in .NET MAUI?

Previously, the runtime architecture for mobile applications was different from the server and desktop .NET ecosystem.

The simplified model was:

.NET MAUI Android/iOS
        |
        v
      Mono

while many other .NET workloads used:

ASP.NET Core
    |
    v
  CoreCLR

.NET 11 brings the mobile platforms onto CoreCLR.

The current .NET 11 Preview 6 architecture is:

.NET MAUI
   |
   +---- Android
   |
   +---- iOS
   |
   +---- Mac Catalyst
   |
   v
 CoreCLR

Microsoft also clarifies that Blazor WebAssembly is not affected by this change and continues to use Mono.

Why CoreCLR Matters

Runtime unification provides several potential benefits.

CoreCLR brings technologies such as:

  • Tiered JIT

  • ReadyToRun

  • Profile-Guided Optimization

  • Shared runtime implementation

  • Common .NET diagnostics tooling

Microsoft describes CoreCLR as providing a stronger foundation for runtime performance and future NativeAOT work on mobile platforms.

However, these capabilities do not guarantee that every application will automatically become faster.

Application startup depends on much more than the runtime:

Startup
  |
  +-- Runtime initialization
  +-- JIT / native code
  +-- Dependency loading
  +-- DI registration
  +-- XAML loading
  +-- Database initialization
  +-- Network calls
  +-- Image loading
  +-- Third-party SDKs

That is why measurement is essential.

Define the Benchmark Before Migrating

A useful benchmark compares the existing application with the migrated version.

For example:

Baseline
.NET 10 + existing runtime
        |
        v
Measurements

Candidate
.NET 11 + CoreCLR
        |
        v
Measurements

Comparison
        |
        v
Decision

Do not change several unrelated application components at the same time.

If you simultaneously:

  • Upgrade .NET

  • Replace the database

  • Rewrite startup logic

  • Upgrade every NuGet package

  • Change image libraries

then you will not know which change caused a performance difference.

Benchmark Environment

A reproducible benchmark should document:

.NET SDK version
.NET MAUI version
Target platform
Device model
OS version
CPU architecture
Build configuration
Linker/trimming settings
AOT configuration
Application build
Network state
Database state

For mobile benchmarks, real devices are especially important.

An emulator can be useful for development, but it should not be the only source of production-performance conclusions.

Microsoft explicitly recommends measuring .NET 11 MAUI applications on target devices and comparing them with the .NET 10 baseline.

Create a .NET 10 Baseline

Before migrating, build the existing application in Release mode.

For example:

dotnet publish \
  -f net10.0-android \
  -c Release
Bash

The exact target framework depends on the project and installed SDK.

Record at least:

Cold startup
Warm startup
Package size
Memory usage
CPU usage
Time to first screen
Time to usable application

Do not rely only on stopwatch measurements.

Automated instrumentation provides more consistent results.

Measure Cold Startup

Cold startup means the application is not already running.

A simplified test looks like:

Force Stop
    |
    v
Launch Application
    |
    v
Runtime Starts
    |
    v
Application Initializes
    |
    v
First Usable Screen

Measure the interval between launch and a clearly defined application-ready event.

For example:

var startupTimer =
    Stopwatch.StartNew();

InitializeApplication();

startupTimer.Stop();

logger.LogInformation(
    "Application startup: {ElapsedMs} ms",
    startupTimer.ElapsedMilliseconds);
C#

The exact location of the timer matters.

If you stop the timer too early, you may measure only framework initialization rather than the time users actually experience.

Define "Startup Complete"

This needs to be explicit.

Possible definitions include:

Application process started
        |
        v
Main page created
        |
        v
Navigation initialized
        |
        v
Initial data loaded
        |
        v
First interactive screen

For a user-facing benchmark, the last meaningful event is often more useful than process creation.

However, the definition should remain consistent between the baseline and migrated builds.

Measure Warm Startup

Warm startup is different.

The operating system may retain application-related resources after the first launch.

A benchmark should therefore distinguish:

Cold Start
Application not running

Warm Start
Application restarted with some resources cached

Run each test multiple times rather than relying on one measurement.

For example:

Cold:
Run 1
Run 2
Run 3
Run 4
Run 5

Warm:
Run 1
Run 2
Run 3
Run 4
Run 5

Then report the distribution.

Avoid publishing a single number without explaining how it was obtained.

Measure Package Size

Runtime changes can also affect application size.

For Android, measure the resulting APK or AAB.

For iOS, measure the resulting IPA or relevant packaged application artifact.

Microsoft specifically recommends comparing package size between the .NET 10 baseline and .NET 11 CoreCLR builds.

Record:

Raw package size
Compressed distribution size where relevant
Architecture-specific size

Be consistent between builds.

Do not compare an Android APK from one configuration with an Android AAB from another and treat the difference as a runtime effect.

Measure Memory Usage

Startup time is only one part of the benchmark.

Measure memory after defined application states.

For example:

Launch
  |
  v
Main Screen
  |
  v
Load Data
  |
  v
Navigate
  |
  v
Open Detail Screen
  |
  v
Return
  |
  v
Measure

This helps detect memory growth caused by application behavior rather than startup alone.

Useful measurements include:

  • Working set

  • Managed heap

  • Allocation rate

  • GC collections

  • Native memory where available

Use .NET Diagnostics

One advantage of CoreCLR on mobile is the availability of familiar .NET diagnostics.

Microsoft states that tools such as dotnet-trace and dotnet-counters can now be used with .NET MAUI mobile applications running on CoreCLR.

That makes it easier for teams already familiar with server-side .NET diagnostics to investigate mobile runtime behavior.

Conceptually:

MAUI Application
      |
      v
CoreCLR
      |
      +---- dotnet-trace
      |
      +---- dotnet-counters
      |
      +---- Runtime Metrics

The exact connection and collection workflow varies by platform and development environment.

Monitor Runtime Counters

Runtime counters can help identify whether an observed slowdown is related to:

  • CPU utilization

  • GC

  • Allocation

  • Threading

  • Exceptions

The important point is correlation.

For example:

Startup slower
     |
     +-- CPU high?
     +-- Allocation high?
     +-- GC activity high?
     +-- Dependency loading slow?

A benchmark result becomes much more useful when you can explain why it changed.

Benchmark Memory After Navigation

A mobile application may appear efficient during startup but accumulate memory as users navigate.

Create a repeatable workflow:

Home
 |
 v
List
 |
 v
Details
 |
 v
Back
 |
 v
List
 |
 v
Details
 |
 v
Back

Repeat the workflow several times.

Measure memory after each cycle.

If memory continuously increases, investigate whether the application has:

  • Event-handler leaks

  • Unreleased subscriptions

  • Cached images

  • Retained pages

  • Native resource leaks

  • Long-lived references

Do not automatically attribute memory growth to CoreCLR.

The runtime is only one part of the application.

Compare Equivalent Builds

The baseline and candidate should be as similar as possible.

For example:

Variable.NET 10 Baseline.NET 11 Candidate
Application sourceSameSame
DeviceSameSame
OSSameSame
Build modeReleaseRelease
DataSameSame
NetworkSameSame
Test workflowSameSame
RuntimeBaselineCoreCLR

This makes the runtime migration the primary experimental variable.

Test Real Application Flows

A benchmark based only on:

Launch -> Exit

is not enough for a complex application.

Microsoft recommends exercising the complete application flow, including navigation, data loading, and platform-specific integrations.

A realistic workflow might be:

Launch
  |
  v
Authentication
  |
  v
Dashboard
  |
  v
Load API data
  |
  v
Open list
  |
  v
Open detail
  |
  v
Perform action
  |
  v
Return to dashboard

This exposes issues that a startup-only benchmark cannot detect.

Test Platform-Specific Integrations

.NET MAUI applications frequently use platform-specific capabilities.

Examples include:

  • Camera

  • Location

  • Notifications

  • Bluetooth

  • Files

  • Sensors

  • Media

  • Native SDKs

Test them separately.

For example:

CoreCLR Migration
       |
       +-- Android API
       +-- iOS API
       +-- Mac Catalyst API
       +-- Third-party native SDK

A runtime migration can expose compatibility problems in libraries that depend on reflection, dynamic code generation, or runtime-specific behavior.

Microsoft specifically calls out third-party libraries using reflection, dynamic code generation, or Mono-specific APIs as areas that should be validated during the transition.

Review Reflection-Heavy Libraries

Reflection can be important for:

  • Dependency injection

  • Serialization

  • ORMs

  • Plugin systems

  • Dependency discovery

  • Native bindings

Audit libraries that dynamically inspect types.

For example:

var type =
    Type.GetType(typeName);
C#

The code may work under one runtime configuration but behave differently when trimming, AOT, or runtime assumptions change.

Do not assume a successful startup proves that all dynamically discovered types remain available.

Test the actual feature.

Review Dynamic Code Generation

Some libraries rely on:

Expression Trees
Reflection.Emit
Dynamic Methods
Runtime-generated proxies

These areas deserve additional testing when moving between runtime and compilation configurations.

If a third-party component documents runtime-specific requirements, follow the vendor's compatibility guidance.

CoreCLR and ReadyToRun

CoreCLR can use ReadyToRun (R2R) images to reduce some runtime compilation work.

Microsoft describes partial R2R and packaged PGO profiles as part of the .NET 11 mobile performance work.

Conceptually:

Application Assembly
       |
       v
ReadyToRun Code
       |
       v
Runtime
       |
       v
Reduced JIT Work

This can affect startup behavior, but the actual result depends on the application.

Therefore, measure startup rather than assuming R2R will produce a specific percentage improvement.

CoreCLR and PGO

Profile-Guided Optimization can use runtime behavior to improve generated code.

The simplified concept is:

Application Execution
        |
        v
Profile Information
        |
        v
Optimization
        |
        v
Application Build/Runtime

Again, the correct engineering approach is measurement.

If your application has a different execution profile from the workload used to produce an optimization profile, the results may differ.

Compare Memory Under Load

Do not only measure memory immediately after launch.

Create defined checkpoints:

T0  = After launch
T1  = After login
T2  = After initial data load
T3  = After navigation
T4  = After repeated workflow
T5  = After idle period

Then compare:

.NET 10
vs
.NET 11 CoreCLR

The resulting graph or table can reveal whether memory stabilizes or continually increases.

Do not claim a memory improvement unless the measurements support it.

Example Benchmark Table

A final article or engineering report can use a table such as:

Metric.NET 10.NET 11 CoreCLRDifference
Cold startupMeasureMeasureCalculate
Warm startupMeasureMeasureCalculate
Package sizeMeasureMeasureCalculate
Initial memoryMeasureMeasureCalculate
Memory after workflowMeasureMeasureCalculate
Allocation rateMeasureMeasureCalculate

The numbers should come from your actual test environment.

For example, if baseline startup is 1,000 ms and the candidate is 900 ms:

Improvement =
(1000 - 900) / 1000 * 100
= 10%

Do not substitute hypothetical values into a production benchmark.

Automate the Benchmark

Manual measurements are useful for initial validation, but automation improves repeatability.

A benchmark harness can record:

{
  "runtime": "net11.0",
  "platform": "android",
  "device": "test-device",
  "build": "Release",
  "coldStartupMs": 0,
  "warmStartupMs": 0,
  "packageSizeBytes": 0,
  "memoryBytes": 0
}
JSON

The actual values should be populated by the measurement system.

Then compare baseline and candidate builds automatically.

Test on Multiple Devices

Mobile hardware varies significantly.

A single device cannot represent every target.

Where practical, test representative device classes:

Older / lower-end
        |
        v
Mid-range
        |
        v
High-end

For iOS, similarly test the device generations relevant to the application's supported deployment range.

The objective is not to benchmark every device.

It is to identify whether runtime behavior changes significantly across the supported hardware range.

Test Android and iOS Separately

Do not combine platform results.

Use:

Android
.NET 10 -> .NET 11

iOS
.NET 10 -> .NET 11

Mac Catalyst
.NET 10 -> .NET 11

Microsoft reports different performance characteristics across platforms during the CoreCLR transition. Its Preview 6 update states that iOS and Mac Catalyst are generally faster than Mono, while Android was within 10 percent of Mono for startup and app size in Microsoft's reported validation. These are Microsoft's observations, not a universal benchmark for every application.

Your application's results can differ.

Common Migration Problems

Startup Gets Worse

Do not immediately revert the migration.

Profile startup first.

Look at:

Dependency loading
JIT
R2R
PGO
Reflection
Third-party libraries
Network calls
Database initialization

Microsoft has acknowledged reports of startup and package-size regressions in some larger Android applications during the preview transition.

Package Size Increases

Compare the complete packaged artifact.

Then inspect:

Runtime
Native libraries
Managed assemblies
Resources
Architecture-specific binaries

Determine whether the increase comes from the runtime or another dependency.

A Third-Party Library Stops Working

Check whether the library depends on:

  • Mono-specific APIs

  • Reflection

  • Dynamic code generation

  • Native bindings

  • Runtime assumptions

Then check the library's compatibility information.

Hot Reload Behaves Differently

Hot Reload and debugging have been actively evolving during the CoreCLR mobile transition.

Microsoft reports substantial progress in Preview 6 while noting that some scenarios remain in progress.

Do not use development-time tooling behavior as the only reason to reject a Release build migration.

Common Benchmarking Mistakes

Comparing Different Devices

Always use the same device when comparing two builds.

Comparing Debug With Release

Use Release builds for production-oriented performance measurements.

Measuring Only Startup

Startup is important, but it is only one part of mobile performance.

Measuring Only Memory

Memory should be evaluated alongside allocations, GC behavior, application state, and native resources.

Using One Run

One measurement can be affected by background processes, caches, thermal conditions, and other variables.

Changing Application Code During the Experiment

If you optimize the application between baseline and candidate measurements, the runtime is no longer the only variable.

Publishing Microsoft's Numbers as Your Own

Microsoft's published observations are useful context, but they should not be presented as a benchmark of your application.

A Practical Migration Benchmark Workflow

Use this sequence:

Build .NET 10 Baseline
        |
        v
Release to Test Device
        |
        v
Measure Startup
        |
        v
Measure Memory
        |
        v
Measure Package Size
        |
        v
Run Full App Workflow
        |
        v
Migrate to .NET 11
        |
        v
Repeat Identical Tests
        |
        v
Compare Results
        |
        v
Investigate Regressions

This approach produces evidence rather than assumptions.

Best Practices

  1. Create a .NET 10 baseline before migration.

  2. Use .NET 11 Release builds for comparison.

  3. Test on real target devices.

  4. Keep the test workload identical.

  5. Measure cold and warm startup separately.

  6. Measure package size.

  7. Measure memory at multiple application states.

  8. Use runtime diagnostics when investigating differences.

  9. Test third-party libraries explicitly.

  10. Test complete application flows.

  11. Separate Android, iOS, and Mac Catalyst results.

  12. Do not assume CoreCLR produces the same improvement for every application.

  13. Record the exact SDK, runtime, device, and build configuration.

  14. Do not publish benchmark numbers that you did not actually measure.

Frequently Asked Questions

Is CoreCLR now the default for .NET MAUI in .NET 11?

More precisely, as of .NET 11 Preview 6, CoreCLR is the only runtime for .NET MAUI mobile apps targeting Android, iOS, and Mac Catalyst. The previous Mono selection path has been removed for those targets in that preview.

Does this affect Blazor WebAssembly?

No.

Microsoft explicitly states that Blazor WebAssembly continues to use Mono and is not affected by this .NET 11 MAUI runtime transition.

Will CoreCLR automatically make my MAUI application faster?

No universal guarantee should be made.

Microsoft reports positive results in its validation, but also acknowledges application-specific regressions, particularly in some Android scenarios. The recommended approach is to measure your own application against its .NET 10 baseline.

What should I benchmark first?

Start with:

  1. Cold startup

  2. Warm startup

  3. Package size

  4. Initial memory

  5. Memory after a representative workflow

Then investigate any meaningful differences with runtime diagnostics.

Should I benchmark an emulator?

An emulator can be useful for development and repeatable functional tests.

For production performance conclusions, prioritize real devices that represent your supported hardware.

Can I continue using Mono with .NET 11 Preview 6?

Microsoft's Preview 6 announcement states that CoreCLR is now the only runtime for .NET MAUI mobile applications on Android, iOS, and Mac Catalyst and that the previous Mono selection path has been removed.

Conclusion

The move from Mono to CoreCLR is one of the most significant runtime changes for .NET MAUI mobile applications.

As of .NET 11 Preview 6, CoreCLR is the only runtime for .NET MAUI Android, iOS, and Mac Catalyst applications. Microsoft has positioned the change around runtime unification, performance foundations, diagnostics, and the longer-term NativeAOT direction.

But the correct migration strategy is not:

.NET 10
   |
   v
.NET 11
   |
   v
Assume Faster

It should be:

.NET 10 Baseline
       |
       v
Measure
       |
       v
.NET 11 CoreCLR
       |
       v
Measure Again
       |
       v
Compare
       |
       v
Profile
       |
       v
Optimize

The most important metrics are not limited to startup time.

A meaningful CoreCLR migration benchmark should examine:

Startup
+
Memory
+
Package Size
+
Allocations
+
GC Behavior
+
Application Workflow
+
Third-Party Compatibility
+
Platform Integrations

The key principle is simple:

Do not migrate to CoreCLR because a benchmark says mobile applications are faster. Migrate with evidence that CoreCLR works correctly and delivers acceptable behavior for your application, devices, and production workload.

The .NET 11 preview window provides an opportunity to establish that evidence before the final release. Microsoft is explicitly asking developers to test their applications now and report reproducible results, making application-specific benchmarking particularly valuable during this transition.

Windows Hosting Recommendation

HostForLIFE.eu receives Spotlight standing advantage award for providing recommended, cheap and fast ecommerce Hosting including the latest Magento. From the leading technology company, Microsoft. All the servers are equipped with the newest Windows Server 2022 R2, SQL Server 2022, ASP.NET Core 7.0.10 , ASP.NET MVC, Silverlight 5, WebMatrix and Visual Studio Lightswitch. Security and performance are at the core of their Magento hosting operations to confirm every website and/or application hosted on their servers is highly secured and performs at optimum level. mutually of the European ASP.NET hosting suppliers, HostForLIFE guarantees 99.9% uptime and fast loading speed. From €3.49/month , HostForLIFE provides you with unlimited disk space, unlimited domains, unlimited bandwidth,etc, for your website hosting needs.
 
https://hostforlifeasp.net/
Read More

Bringing .NET Remoting and AppDomains Back to .NET 8 and 10

Leave a Comment

You have most likely encountered this obstacle if you have ever attempted to convert a long-running.NET Framework program to.NET 8 or.NET10. The code is compiled. Channel Services.In the IDE, RegisterChannel is located directly. AppDomain.The build is green, CreateDomain resolves, IntelliSense is satisfied, and when you run it for the first time, you get:

 

System.PlatformNotSupportedException: Remoting is not supported on this platform.

.NET Core removed the implementations of remoting and application domains but kept some of the type names, which produces exactly this experience: a clean compile followed by a runtime failure. The usual advice is "rewrite it as gRPC" or "rewrite it as WCF Core", and for a small surface that is fine. For an application with two hundred MarshalByRefObject-derived types, server-to-client callbacks, lifetime leases, and a plugin loader built on AppDomain.CreateDomain, "rewrite it" is a project, not a migration.

This article describes a different approach: keep the API and replace what is underneath it. I will walk through two libraries that do this — a remoting compatibility layer and a process-backed AppDomain replacement — the design constraints that shaped them, and the places where the platform simply does not allow an exact match.

The packages multi-target .NET Standard 2.0.NET 8 and .NET 10, and the whole solution — libraries, tests and samples — is built and verified against both modern runtimes.

What Actually Has to Be Replaced

Classic remoting is three things stacked together, and each one is unavailable for a different reason.

Layer.NET FrameworkWhy it is gone
ProxiesCLR transparent proxies (RealProxy)The runtime hook does not exist off .NET Framework
SerializerBinaryFormatterRemoved from the supported surface; throws outright on .NET 9+
TransportTcpChannel (SSPI-secured)The channel plumbing was never ported

So the replacement needs a proxy mechanism, a [Serializable]-aware serializer that is not BinaryFormatter, and a transport. What it does not need to change is the public API — and that is the whole point of the exercise. MarshalByRefObjectRemotingConfigurationChannelServicesObjRefILeaseISponsor and the rest keep their names and signatures.

Part 1: Remoting
Before and After

Here is a classic server registration on .NET Framework:

ChannelServices.RegisterChannel(new TcpChannel(9000), false);
RemotingConfiguration.RegisterWellKnownServiceType(
    typeof(OrderService), "orders.rem", WellKnownObjectMode.Singleton);

And the same thing running on .NET 8 or .NET 10:

using System.Runtime.Remoting;
using System.Runtime.Remoting.Channels;
using System.Runtime.Remoting.Channels.Tcp;

ChannelServices.RegisterChannel(new TcpServerChannel("app", 9000), ensureSecurity: false);
RemotingConfiguration.RegisterWellKnownServiceType(
    typeof(OrderService), "orders.rem", WellKnownObjectMode.Singleton);

The channel type is named TcpServerChannel rather than TcpChannel — separate client and server channel types were always available in classic remoting, and using them makes the direction explicit. The client side:

ChannelServices.RegisterChannel(new TcpClientChannel(), ensureSecurity: false);
var orders = (IOrderService)RemotingServices.Connect(
    typeof(IOrderService), "tcp://server:9000/orders.rem");

RemotingConfiguration.Configure("app.config") still reads a <system.runtime.remoting> section, so if your deployment is configuration-driven rather than code-driven, that keeps working untouched.

A Working Contract

Let me use the sample from the repository, because it exercises the features that usually decide whether a compatibility layer is real or a demo. The contract assembly:

[Serializable]
public class Quote
{
    public string Symbol;
    public decimal Price;
    public DateTime AsOf;
    private string _source = "unset";        // private field: must survive the wire

    public string Source => _source;
    public void SetSource(string s) => _source = s;
}

// The client implements this and hands it to the server; the server calls back into it.public interface ITicker
{
    void OnQuote(Quote quote);
    void OnClosed(string reason);
}

public interface IMarketService
{
    Quote GetQuote(string symbol);
    int Subscribe(ITicker ticker, string symbol);
    void PublishTo(int subscriptionId, decimal price);
    bool TryGetLast(string symbol, out Quote quote);     // out parameter
    void Accumulate(ref decimal running, decimal delta); // ref parameter
    T Echo<T>(T value);                                  // generic method
    void Boom(string message);                           // exception marshalling
    string WhoAmI();
}

The server implementation is ordinary code:

public class MarketService : MarshalByRefObject, IMarketService
{
    private readonly Dictionary<int, (ITicker Ticker, string Symbol)> _subscriptions = new();
    private static int _nextSubscription = 1;

    public int Subscribe(ITicker ticker, string symbol)
    {
        // `ticker` arrived as an ObjRef pointing back at the client. Calling it sends a request
        // in the reverse direction over the connection the client already opened.
        var id = _nextSubscription++;
        _subscriptions[id] = (ticker, symbol);
        ticker.OnQuote(GetQuote(symbol));
        return id;
    }

    public T Echo<T>(T value) => value;

    public void Boom(string message) => throw new InvalidOperationException(message);

    public string WhoAmI() => "server-pid-" + Environment.ProcessId;
}

That WhoAmI is not decoration. It is how you prove a remoting layer is actually remoting: the client asserts that the process id it gets back is not its own. A layer that quietly short-circuits to an in-process call would pass every other test in the suite.

The Callback Improvement

This is the part where the replacement is better than the original, so it is worth dwelling on.

In classic remoting, if you wanted the server to call back into the client, the client had to register its own receiver channel and be externally addressable. That is a firewall problem, a NAT problem, and in a container it is often simply impossible.

Here, the client registers a client channel only:

// A client channel only. Note there is no server channel here at all — callbacks still work.
ChannelServices.RegisterChannel(new TcpClientChannel("sample-client", null), ensureSecurity: false);

var market = (IMarketService)RemotingServices.Connect(typeof(IMarketService), url);

var ticker = new Ticker();                              // : MarshalByRefObject, ITickervar subscription = market.Subscribe(ticker, "ACME");    // server now holds a reference to us

market.PublishTo(subscription, 123.75m);                // server pushes to us
Console.WriteLine(ticker.Quotes[^1].Price);             // 123.75

No listening port on the client. This works because the connection is framed, duplex and multiplexed: one TCP connection carries calls in both directions, correlated by request id. When the server invokes ticker.OnQuote(...), the reverse call travels back down the connection the client already opened.

The routing rule that makes this safe is worth stating precisely, because the naive version is wrong. A client with no receiver channel marshals its objects under a bare uri with no scheme — there is nothing to dial. So ConnectionContext publishes the connection a call arrived on for the duration of dispatch, and a reference with no dialable url binds its calls back to that connection. A reference that does carry a channel url is dialed normally. That distinction is what prevents a third-party reference — A hands B a reference to C — from being misrouted through the A↔B connection.

The Serializer

BinaryFormatter is not an option, so the layer carries its own [Serializable]-aware binary serializer. It honours the full classic contract, which is more than most people remember is in there:

  • private fields and [NonSerialized]

  • ISerializable with GetObjectData / the deserialization constructor

  • IObjectReferenceIDeserializationCallback, and the [OnSerializing] / [OnDeserialized] family

  • serialization surrogates

  • object cycles and shared references (identity preserved, not duplicated)

  • arrays: single-dimension, jagged, multi-dimensional, and non-zero lower bound

Two constraints are new, and both exist because the sender should not get to decide how much work the receiver does:

RemotingConfiguration.MaxGraphDepth  = 128;         // default
RemotingConfiguration.MaxFrameLength = 64 * 1024 * 1024;

The depth bound is not paranoia about malice alone. Reading a graph is recursive, so graph depth is stack depth — 50,000 nested arrays fit in 250 KB of payload and overflow the reader's stack, which you cannot catch and which takes the process with it. Declared element counts are separately checked against the bytes actually remaining in the payload: every element costs at least one byte, so a 10-byte message claiming 100,000,000 elements is lying, and the check catches it before the allocation.

Both bounds are enforced on the writing side too, so a graph that could never be read fails on the side that can still do something about it, with an error message naming the property to raise.

Deserialization Safety

TypeFilterLevel.Low is the default for a network endpoint, matching .NET Framework. There is one deliberate improvement: a deny list that applies at every level, including Full. .NET Framework's Full had no deny list at all.

The deny list names known gadget-chain entry points — types whose deserialization turns into code execution — and matches on the base chain, not the type's own name. That detail matters: matching by name alone made the System.IO.FileSystemInfo entry inert, because it is abstract and only its differently-named subclasses can be constructed.

Two related rules:

  • Delegates are refused on the general object path. A delegate reached through an object graph is a classic gadget step. There is a dedicated delegate record where the shape is known and the receiver decides whether to invoke; a static delegate is only reconstructed at TypeFilterLevel.Full.

  • Type names on an incoming message resolve only against already-loaded assemblies. Type.GetType loads assemblies by name, so resolving a wire-supplied name would let a caller choose which assembly the server loads — and run its module initializer — before dispatch. Nothing legitimate is lost: for the server to hold an object satisfying a contract, that contract's assembly is loaded by definition.

TLS

Classic remoting's secure="true" on the TCP channel meant SSPI/Negotiate — Windows authentication, not transport encryption. There is no portable equivalent, so the encryption story here is deliberately different and deliberately named differently:

server.Security = TcpChannelSecurity.ForServer(certificate);
client.Security = TcpChannelSecurity.ForClient("service.example.com");

A client that cannot chain the certificate — a self-signed cert in a closed deployment — opts out with ForClientWithoutValidation(). That is a named setting rather than a validation callback returning true, because the callback-returning-true is the single most common way TLS gets silently disabled in production.

For same-machine communication there is also an IpcChannel speaking the same framed protocol over named pipes, with ipc://portName/objectUri urls. No port means nothing is reachable from off the machine and nothing can collide with another process binding the same port.

Part 2: Application Domains
A Domain Is Now a Process

AppDomain.CreateDomain cannot be revived. The CLR has no second domain to create — this is not a missing API, it is a missing runtime feature. So a domain becomes an operating system process, and the remoting layer above carries the calls.

// Before (.NET Framework)var domain = AppDomain.CreateDomain("plugin");
var plugin = (IPlugin)domain.CreateInstanceAndUnwrap("Acme.Plugin", "Acme.Plugin.Entry");
plugin.Run();
AppDomain.Unload(domain);

// Aftervar domain = AppDomain.CurrentDomain.CreateChildDomain("plugin");
var plugin = (IPlugin)domain.CreateInstanceAndUnwrap("Acme.Plugin", "Acme.Plugin.Entry");
plugin.Run();
domain.Unload();

Only the two statics change, and only because C# has no static extension methods. SetData / GetDataDoCallBackFriendlyNameBaseDirectoryIsDefaultAppDomain() and CreateInstanceAndUnwrap keep their names and signatures.

Static State, Isolated — the Original Reason Domains Existed

using var first  = AppDomain.CurrentDomain.CreateChildDomain("tenant-a");
using var second = AppDomain.CurrentDomain.CreateChildDomain("tenant-b");

var a = first.CreateInstanceAndUnwrap<Plugin>(new object[] { "tenant-a" });
var b = second.CreateInstanceAndUnwrap<Plugin>(new object[] { "tenant-b" });

a.Execute("one");
a.Execute("two");
b.Execute("one");

// Plugin holds a private static int _callsInThisDomain
Console.WriteLine(a.Summarise().Calls);   // 2
Console.WriteLine(b.Summarise().Calls);   // 1
Console.WriteLine(a.ProcessId != b.ProcessId);   // True
Isolation That the Original Could Not Deliver

This is where a process beats a domain outright. A .NET Framework application domain shared a process, so a stack overflow, a corrupt native heap or an Environment.FailFast in the plugin killed the host along with it. Domains offered code isolation, never fault isolation.

var domain = AppDomain.CurrentDomain.CreateChildDomain("doomed");
var plugin = domain.CreateInstanceAndUnwrap<Plugin>();

try { plugin.Crash(); }              // calls Environment.FailFast inside the childcatch (Exception) { /* the connection dies with the process; that is the point */ }

while (domain.IsAlive) Thread.Sleep(50);

try { domain.CreateInstanceAndUnwrap<Plugin>(); }
catch (AppDomainUnloadedException) { /* expected */ }

// ...and this application is still running.using var healthy = AppDomain.CurrentDomain.CreateChildDomain("recovered");

The parent observes AppDomainUnloadedException — the same exception classic code already handled — and carries on. Nothing in the calling code needs to know that the mechanism changed.

DoCallBack, and Why a Lambda Cannot Work
public static class Settings
{
    // Target of a DoCallBack. Must be static — a closure cannot cross the boundary.
    public static void ApplyDefaults()
    {
        Write("mode", "configured-by-callback");
        Write("pid", Process.GetCurrentProcess().Id.ToString());
    }
}

domain.DoCallBack(Settings.ApplyDefaults);      // runs INSIDE the domain

A lambda that captures local state compiles to a closure object which has no identity the child process can reach, so it is rejected with an explanation rather than silently misbehaving:

var captured = 1;
domain.DoCallBack(() => captured++);   // throws, message mentions MarshalByRefObject
The Complete List of Required Source Changes

This is the part of any compatibility layer that decides whether it is usable. Here it is exhaustive.

1. Members on a class contract must be virtual

The CLR's transparent proxy intercepted every member. A generated proxy is a subclass, and a subclass can only override virtual members. A non-virtual one would run locally on the caller and silently return wrong results:

public class OrderService : MarshalByRefObject
{
    public virtual Order Get(int id) { ... }   // add 'virtual'
}

Two non-obvious points. First, IsVirtual is not the test — an implicit interface implementation is compiled virtual final, which is just as un-overridable, so it is reported too. Second, remoting an interface avoids the issue entirely, because interface slots are separately re-implemented and are therefore always intercepted. An interface-typed contract is the recommended shape.

Rather than allow a silent wrong answer, proxy creation fails at creation time and names every offender. A non-overridable member cannot even be guarded: DefineMethodOverride against a non-virtual method fails at CreateTypeInfo() with TypeLoadException, and a new member is never reached through a base-typed reference. Failing loudly at the earliest possible point is the only honest option.

2. Client-activated objects need an explicit factory

new Foo() was routed through activation by the CLR, and there is no hook for that off .NET Framework:

var session = RemotingActivator.CreateInstance<Session>(userId);   // was: new Session(userId)

The url-taking form is named CreateInstanceAt rather than being an overload — with both present, CreateInstance<Cart>("owner") binds to the url overload and silently treats a constructor argument as an endpoint address. That bug showed up in the sample within minutes of the overload existing.

3. Domain creation and unload
AppDomain.CurrentDomain.CreateChildDomain("worker")   // was: AppDomain.CreateDomain("worker")
domain.Unload()                                       // was: AppDomain.Unload(domain)
4. A few members could not keep their signature

AssemblyAppDomainSetup, evidence and — less obviously — AssemblyName cannot cross a process boundary. AssemblyName.GetObjectData throws PlatformNotSupportedException off .NET Framework, which is the sort of thing you only discover by measuring.

ClassicReplacement
AppDomainSetupAppDomainSetup2 — same property names; the platform owns the original type on .NET 8 or .NET 10
domain.Load(name) → Assemblydomain.LoadAssembly(name) → AssemblyName
domain.GetAssemblies() → Assembly[]domain.GetLoadedAssemblies() → AssemblyName[]
AssemblyResolve returning AssemblyAssemblyResolve returning a path or raw bytes
domain.SetupInformationdomain.Setup

Settings with no meaning off .NET Framework — ShadowCopyFilesLoaderOptimization, evidence, permission sets — are deliberately not declared at all. A settable property that silently does nothing is a latent bug; a compile error is a prompt to think about what the code actually needed.

5. Suppress two obsoletion warnings
<NoWarn>$(NoWarn);SYSLIB0010;CS0672</NoWarn>
XML

MarshalByRefObject.InitializeLifetimeService is [Obsolete] on modern .NET (SYSLIB0010) and overriding it also raises CS0672. Both are harmless here: the library calls the override itself and handles the PlatformNotSupportedException the base implementation throws — which is what lets a class that returns its own lease keep working, and one that defers to base keep working too.

That is the whole list.

What You Give Up

An honest accounting matters more than a feature table, so:

  • No wire compatibility with unmodified .NET Framework peers. Both ends must reference these packages. This was the deliberate trade that allowed a clean protocol and a serializer that is not BinaryFormatter.

  • No AOT, no full trimming. Proxies are generated with Reflection.Emit.

  • No HTTP/SOAP channel, no ContextBoundObject, no context attributes. SOAP serialization has no supported equivalent off .NET Framework; ref="http" in a config file throws and names tcp and ipc as the alternatives.

  • Domains cost more to start — roughly 50–100 ms against about 1 ms. Pool and reuse them rather than creating one per unit of work.

  • A domain is not a security boundary. Domains talk over loopback TCP bound to 127.0.0.1 with unguessable capability uris. That is hardening, not isolation — another process running as the same user can still reach one. Do not use domains to sandbox code you do not trust.

  • One thread per in-flight call. IMessageSink.SyncProcessMessage is synchronous by contract, so a caller blocks a thread until the reply arrives. A call graph that bounces between processes consumes a thread per hop, and the thread pool injects new threads at roughly one per 500 ms once saturated — which surfaces as calls that take seconds and then recover. Deployments with deep callback chains should raise ThreadPool.SetMinThreads. Fixing this properly would mean abandoning the IMessageSink shape, and that shape is what makes this a compatibility layer rather than a new framework.

  • Emitted proxy types are never unloaded. The factory uses AssemblyBuilderAccess.Run and caches each contract's proxy for the process lifetime, which is what makes the second call cheap. Bounded by the number of distinct contracts — a deployment constant, not a function of traffic.

  • Lifetime leases are lazy. A lease is created only when something asks for one, so a published object with no lease is never reaped. A long-lived domain that creates a worker per unit of work must call domain.Release(instance); nothing else will.

Installing

Remoting only:

<PackageReference Include="Net4x.Runtime.Remoting" Version="1.0.0" />

Application domains — both packages are required:

<PackageReference Include="Net4x.AppDomain" Version="1.0.0" /><PackageReference Include="Net4x.AppDomain.Host" Version="1.0.0" />

Net4x.AppDomain is the API you compile against. Net4x.AppDomain.Host contains no API at all — it delivers the host program, one process of which is started per domain. It has to be a separate package because NuGet never copies a tools/ folder into your output directory, and a bare executable in lib/ would arrive without the runtimeconfig.json that dotnet exec needs. The host package carries build targets that place the complete host at tools/net8.0/ and tools/net10.0/ in your output folder, which is where the launcher looks.

Reference Net4x.AppDomain alone and it compiles fine, then throws on the first CreateChildDomain with a message listing every path it searched. Fail loudly, and name the fix.

The Host Must Match Your Runtime

Both host builds ship, and the launcher picks the one matching the runtime the parent is on: an exact match first, then the closest older build, then a newer one. That ordering is not cosmetic. A child process running .NET 8 cannot load an assembly built for .NET 10 — and the failure does not say so. The assembly loads at metadata level and then Assembly.GetType simply returns null, so what you get is:

Type 'Acme.Plugin.Entry' was not found in assembly 'Acme.Plugin' inside domain 'plugin'.

A type-not-found message for a type that plainly is in the assembly, with nothing pointing at the real cause. It is worth knowing the shape of this if you ever hand-deploy the host or pin it to one framework: the host under tools/ has to be at least as new as the code you are loading into it. The library now resolves types in the child with throwOnError: true so the underlying load failure is named, and the error mentions the host's runtime version and the tools/<tfm> layout.

How It Is Verified

Claims about a cross-process library are cheap, so here is what actually runs:

dotnet build Net4x.Runtime.Remoting.slnx -c Release
dotnet test  Net4x.Runtime.Remoting.Tests\Net4x.Runtime.Remoting.Tests.csproj   # 144 tests
dotnet test  Net4x.AppDomain.Tests\Net4x.AppDomain.Tests.csproj                 # 49 tests, real processes
pwsh -File   samples\run-cross-process-sample.ps1                               # 17 cross-process checks
pwsh -File   samples\run-appdomain-sample.ps1                                   # 24 domain checks

Every line of that runs twice, once per target framework: the test projects multi-target net8.0 and net10.0, and both sample scripts loop over the two. That is not redundancy. A domain test only exercises the host build for its own framework, so running only the newest one would leave the .NET 8 host completely untested while still shipping it in the package.

The unit tests alone cannot catch a broken cross-process path. Every one of the 49 domain tests spawns real child processes, the cross-process sample starts two separate executables talking over a real socket, and both compare Environment.ProcessId across the boundary — so they fail if anything is ever quietly served in-process. The cross-process sample is also the only thing that puts the contract in a third assembly, which is what catches an over-strict change to type or method resolution.

Latest cross-process run:

PASS  simple call returns a value
  PASS  decimal survives the wire
  PASS  DateTime survives with its Kind
  PASS  private field round-trips
  PASS  call executed in the server process        <- proves it is not an in-process shortcut
  PASS  out parameter comes back
  PASS  ref parameter comes back
  PASS  generic method (string)
  PASS  generic method (int)
  PASS  server exception propagates with type and message
  PASS  subscribe returned an id
  PASS  server called back into the client during Subscribe
  PASS  server pushed a later callback
  PASS  callback carried the pushed payload
  PASS  client-activated object keeps per-client state
  PASS  client-activated constructor argument was honoured
  PASS  each activation is a distinct instance

ALL CHECKS PASSED

Three Bugs Worth Knowing About

Because they are the kind you would hit yourself building anything similar, and each one produced a silently wrong answer rather than an exception.

Array type names on the wire. The assembly qualifier must go after the array / byref / pointer suffix. System.Int32, MyAsm[] parses back as int, not int[] — so every array-typed payload was quietly corrupted until the suffix order was fixed.

Open generic definitions. A generic method's metadata table entry is the open definition. Passing it across the wire delivers an unbound T whose parameter types have no resolvable names, and every generic call fails server-side. It must be closed at the call site.

IsVirtual as the interceptability test. As above: virtual final passes IsVirtual and cannot be overridden. Sealed overrides and implicit interface implementations sailed through the check and then ran locally on the client.

Conclusion
Not every migration should keep its remoting. If your remote surface is a handful of service calls, gRPC or ASP.NET Core minimal APIs will give you a better result and a smaller dependency footprint.

But "rewrite the distribution layer" is not always a proportionate answer to "we want to run on a supported runtime". When the remoting surface is large, deeply entangled with MarshalByRefObject identity semantics, or reliant on lifetime leases and bidirectional callbacks, a compatibility layer lets you move to .NET 8 or .NET 10 first and modernise the architecture afterwards — as a choice rather than as a prerequisite.

And in two places the replacement is simply better than what it replaces. Callbacks no longer require a reachable, addressable client, so a client behind NAT or in a container can receive server pushes over the connection it already opened. And domain isolation is now real fault isolation: a plugin that hard-crashes its process can no longer take your application down with it. 

Windows Hosting Recommendation

HostForLIFEASP.NET receives Spotlight standing advantage award for providing recommended, cheap and fast ecommerce Hosting including the latest Magento. From the leading technology company, Microsoft. All the servers are equipped with the newest Windows Server 2012 R2, SQL Server 2014, ASP.NET 7.0.4, ASP.NET MVC 6.0, Silverlight 5, WebMatrix and Visual Studio Lightswitch. Security and performance are at the core of their Magento hosting operations to confirm every website and/or application hosted on their servers is highly secured and performs at optimum level. mutually of the European ASP.NET hosting suppliers, HostForLIFE guarantees 99.9% uptime and fast loading speed. From €3.49/month , HostForLIFE provides you with unlimited disk space, unlimited domains, unlimited bandwidth,etc, for your website hosting needs.
 
https://hostforlifeasp.net/
Read More
Previous PostOlder Posts Home