AI Native  

Benchmarking Stateless MCP Servers Behind Load Balancers

Model Context Protocol (MCP) over HTTP has traditionally involved session state. A client could establish a session with a server and subsequent requests would remain associated with that session. That model works, but it introduces an operational constraint when an MCP server is deployed across multiple instances: the load balancer needs session affinity, or the application needs a shared mechanism for maintaining session state.

MCP's 2026-07-28 protocol revision changes that architecture. The MCP C# SDK 2.0 makes HTTP transport stateless by default. Requests no longer depend on an Mcp-Session-Id, which allows ordinary HTTP infrastructure to distribute requests across server instances without protocol-level session affinity. Microsoft specifically highlights horizontal scaling, serverless deployments, and multi-instance environments as benefits of this model.

That raises an important engineering question:

How much does stateless MCP actually change the behavior of a .NET application under load?

Rather than assuming that statelessness is automatically faster, this article presents a reproducible benchmarking approach for measuring throughput, latency, resource consumption, and scaling behavior behind a load balancer.

What Are We Actually Benchmarking?

The goal is not simply to compare two HTTP endpoints.

A useful benchmark should measure the behavior of an MCP server under conditions that resemble its production deployment.

The experiment should compare at least these dimensions:

DimensionStateless MCPStateful MCP
Transport sessionNoneMaintained
Session affinityNot requiredUsually required
Horizontal scalingAny instance can handle requestsRequests may need the same instance
Shared session storeNot required at protocol layerMay be required
Request routingOrdinary HTTP load balancingSession-aware routing
Server memoryNo transport session per clientSession state consumes resources
Failure handlingRequest can reach another instanceSession may need recovery
Deployment complexityLower for stateless workloadsHigher
Server-to-client unsolicited messagesNot supported in stateless modeSupported
Best fitScalable remote MCP servicesFeatures requiring transport sessions

The SDK documentation explicitly describes stateless Streamable HTTP as suitable for production deployments and horizontal scaling without session affinity. Stateful mode remains appropriate when the application requires session-specific transport behavior.

Build a Minimal Stateless MCP Server

Start with a small ASP.NET Core application.

Install the HTTP integration:

dotnet add package ModelContextProtocol
dotnet add package ModelContextProtocol.AspNetCore

Configure the MCP server:

using ModelContextProtocol.Server;
using System.ComponentModel;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddMcpServer()
    .WithHttpTransport(options =>
    {
        options.Stateless = true;
    })
    .WithToolsFromAssembly();

var app = builder.Build();

app.MapMcp("/mcp");

app.Run();

In SDK 2.0, Stateless is already true by default, so the explicit setting above makes the benchmark's intent obvious. A stateless server does not create transport sessions or assign Mcp-Session-Id.

Now add a deliberately simple tool:

[McpServerToolType]
public static class BenchmarkTools
{
    [McpServerTool]
    [Description("Returns a simple response for load testing.")]
    public static string Echo(string message)
    {
        return $"Processed: {message}";
    }
}

This intentionally contains almost no business logic.

That is important because the first benchmark should measure MCP and HTTP infrastructure rather than database latency, external API latency, or CPU-heavy application logic.

Add an Identifiable Server Instance

When testing a load-balanced deployment, you need to determine whether requests are actually reaching different instances.

One simple approach is to expose an instance identifier.

[McpServerToolType]
public static class DiagnosticsTools
{
    private static readonly string InstanceId =
        $"{Environment.MachineName}-{Guid.NewGuid():N}";

    [McpServerTool]
    [Description("Returns the server instance handling the request.")]
    public static string GetInstance()
    {
        return InstanceId;
    }
}

This is useful during testing because repeated calls can show whether the load balancer is distributing requests across multiple instances.

For production observability, a stable deployment or container identifier is preferable to a randomly generated process identifier.

Deploy Multiple MCP Instances

A realistic benchmark should contain more than one application instance.

For example:

                     Load Generator
                           |
                           v
                    +-------------+
                    | Load Balancer|
                    +-------------+
                       /    |    \
                      /     |     \
                     v      v      v
                 MCP-01  MCP-02  MCP-03
                     \      |      /
                      \     |     /
                       +----+----+
                            |
                       Backend APIs

The important characteristic is that the load balancer should not use MCP session affinity for the stateless test.

A request can therefore follow this path:

Request 1 -> MCP-01
Request 2 -> MCP-03
Request 3 -> MCP-02
Request 4 -> MCP-01

That is the behavior the benchmark should verify.

Use a Real Load Balancer

For a local reproducible experiment, NGINX is sufficient.

A simplified configuration might look like:

events {}

http {
    upstream mcp_backend {
        server mcp01:8080;
        server mcp02:8080;
        server mcp03:8080;
    }

    server {
        listen 80;

        location /mcp {
            proxy_pass http://mcp_backend;

            proxy_http_version 1.1;

            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

There is deliberately no sticky-session configuration.

The objective is to test whether ordinary HTTP routing is sufficient for the stateless MCP deployment.

Design the Benchmark Workload

A benchmark is only useful if the workload is defined before measurements are collected.

At minimum, define:

  1. Number of MCP server instances.

  2. CPU and memory limits.

  3. .NET runtime version.

  4. MCP SDK version.

  5. Load generator location.

  6. Request rate or concurrency.

  7. Request payload size.

  8. Tool execution time.

  9. Duration of the test.

  10. Warm-up period.

  11. Metrics collected.

  12. Failure threshold.

For example:

Scenario: Stateless MCP
Instances: 1, 2, 3, 5
Concurrency: 10, 50, 100, 250
Duration: 5 minutes per scenario
Warm-up: 60 seconds
Operation: tools/call
Payload: small JSON request
Backend dependency: none

These values are examples of a test plan, not measured performance results.

The same workload should be executed against every configuration.

Measure the Right Metrics

Latency alone is not enough.

Throughput

Measure completed requests per second.

A stateless architecture should be evaluated for how effectively additional instances increase total capacity.

Latency

Capture:

  • Average latency

  • Median latency

  • p95 latency

  • p99 latency

  • Maximum latency

The median tells you what a typical request experiences. The p95 and p99 values are often more useful for production capacity planning because they expose tail latency.

Error Rate

Record:

HTTP errors
MCP protocol errors
Timeouts
Connection failures
Tool execution failures
Load-balancer failures

A configuration that produces higher throughput but significantly higher error rates is not an improvement.

CPU Utilization

Measure CPU usage independently for:

  • Load balancer

  • MCP instances

  • Backend dependencies

This helps determine whether the application or infrastructure is the bottleneck.

Memory

Record:

  • Working set

  • Managed heap

  • Allocation rate

  • GC activity

The important comparison is how memory behaves as concurrent clients increase.

Instance Distribution

Record how many requests each MCP instance receives.

For example:

MCP-01: 33.1%
MCP-02: 33.7%
MCP-03: 33.2%

Those numbers are only an example of how results might be reported. They should not be presented as actual benchmark results unless they came from a real test run.

Create a Load Test

A tool such as k6 can generate HTTP traffic against the load balancer.

A simplified test could look like this:

import http from 'k6/http';
import { check } from 'k6';

export const options = {
    scenarios: {
        constant_load: {
            executor: 'constant-vus',
            vus: 50,
            duration: '5m'
        }
    }
};

export default function () {
    const payload = JSON.stringify({
        jsonrpc: "2.0",
        id: Date.now(),
        method: "tools/call",
        params: {
            name: "echo",
            arguments: {
                message: "benchmark"
            }
        }
    });

    const response = http.post(
        'http://localhost/mcp',
        payload,
        {
            headers: {
                'Content-Type': 'application/json'
            }
        }
    );

    check(response, {
        'request succeeded': r =>
            r.status >= 200 && r.status < 300
    });
}

The exact request format should be aligned with the MCP protocol version and client behavior used in the test environment. The benchmark should ideally use an actual MCP client for protocol-level validation and use a raw HTTP load generator as a complementary stress test.

Benchmark One Instance First

Do not immediately benchmark five instances.

Start with one server:

Load Generator
      |
      v
   MCP-01

This establishes the baseline.

Record:

Throughput
p50
p95
p99
CPU
Memory
Errors

Then repeat with two instances:

             +-- MCP-01
Load Balancer|
             +-- MCP-02

Continue with three and five instances.

The important question is not:

"How fast is MCP?"

Instead ask:

"How does capacity change when I add MCP instances?"

Calculate Scaling Efficiency

Suppose a one-instance test produces a measured throughput of T1.

With N instances, calculate:

Scaling Efficiency =
Measured Throughput / (T1 × N) × 100

For example, if the one-instance baseline is 1,000 requests/second and a three-instance deployment achieves 2,700 requests/second:

Scaling Efficiency =
2700 / (1000 × 3) × 100

= 90%

This example demonstrates the calculation only. It is not a benchmark result.

The gap between theoretical and measured scaling can come from:

  • Load-balancer overhead

  • CPU saturation

  • Network limits

  • Connection limits

  • Garbage collection

  • Logging

  • Backend dependencies

  • Load-generator capacity

Test Statelessness Across Instances

Performance is only one part of the experiment.

The benchmark should also prove that the server does not accidentally depend on transport session state.

The diagnostic tool introduced earlier can help:

var instance = await client.CallToolAsync(
    "get_instance",
    new Dictionary<string, object?>());

Console.WriteLine(instance);

Repeated calls through the load balancer should be capable of reaching different instances.

The important validation is that a request does not fail merely because the next request reaches another server.

This is the architectural property that differentiates the stateless deployment.

Benchmark Stateful Mode as a Control

A useful experiment also runs a stateful configuration.

builder.Services
    .AddMcpServer()
    .WithHttpTransport(options =>
    {
        options.Stateless = false;
    })
    .WithToolsFromAssembly();

This should not be treated as a simple "old versus new" speed contest.

The purpose is to measure the operational consequences of maintaining transport sessions.

Compare:

MeasurementStatelessStateful
ThroughputMeasureMeasure
p95 latencyMeasureMeasure
p99 latencyMeasureMeasure
Memory/clientMeasureMeasure
CPUMeasureMeasure
ErrorsMeasureMeasure
Load distributionMeasureMeasure
Session affinityNoRequired where sessions are used
Cross-instance requestsSupportedSession-aware
Failure recoveryRequest-orientedSession-dependent

The table should be populated with measurements from your environment rather than generic numbers.

Test Instance Failure

A production benchmark should include failure injection.

Start three MCP instances:

MCP-01
MCP-02
MCP-03

Generate continuous traffic and then stop one instance.

Measure:

  • Error rate during failure

  • Recovery time

  • p95 latency during recovery

  • Request distribution after recovery

  • Whether traffic returns to the failed instance after it becomes healthy

With a stateless architecture, the protocol does not require subsequent requests to return to a specific server instance. The MCP C# SDK documentation explicitly identifies this as an advantage for horizontal scaling.

However, this does not mean every application automatically becomes failure-proof.

If your tool stores application state only in process memory, losing that instance can still lose that application state.

Stateless Does Not Mean No State

This distinction is critical.

Consider a shopping workflow.

A stateful implementation might rely on:

MCP Session
    |
    +-- Current Cart
    +-- User Context
    +-- Workflow State

A stateless implementation can move that state into an explicit application-level identifier:

Tool Call
    |
    +-- cartId = "abc123"

The server can then retrieve the state from a shared database or distributed cache.

For example:

[McpServerTool]
public async Task<string> GetCart(
    string cartId,
    CancellationToken cancellationToken)
{
    var cart = await cartRepository.GetAsync(
        cartId,
        cancellationToken);

    return cart is null
        ? "Cart not found."
        : cart.ToString();
}

The transport remains stateless while the application can still maintain durable state.

Microsoft specifically recommends this model when application state needs to survive across tool calls: use explicit handles such as a basketId or browserId rather than hiding application state inside transport sessions.

Common Benchmarking Mistakes

Measuring Only Average Latency

Average latency can hide severe tail latency.

Always capture p95 and p99.

Testing Without a Warm-Up

The first requests can include:

  • Application startup

  • JIT compilation

  • Connection establishment

  • Cache initialization

Use a warm-up phase before recording the actual benchmark.

Running the Load Generator on the Same Machine

This can make the load generator compete with the server for CPU, memory, and network resources.

For meaningful capacity testing, isolate the load generator when possible.

Benchmarking a Fake Workload

A zero-cost tool is useful for measuring protocol overhead, but it does not represent a real application.

After the baseline test, create additional scenarios involving realistic workloads such as database queries or downstream HTTP calls.

Ignoring the Load Balancer

Testing the application directly does not validate the architecture.

The actual production path should be tested:

Client
  ↓
Load Balancer
  ↓
MCP Instances
  ↓
Dependencies

Treating Stateless as Automatically Faster

Statelessness primarily changes the state and scaling model.

It does not guarantee lower CPU time or lower per-request latency for every workload.

The benchmark should establish the actual effect.

Recommended Benchmark Matrix

A practical experiment can use this matrix:

ScenarioInstancesConcurrencyStateFailure Injection
Baseline1LowStatelessNo
Scale-out2MediumStatelessNo
Scale-out3MediumStatelessNo
Scale-out5HighStatelessNo
Stateful control3MediumStatefulNo
Failure test3MediumStatelessStop 1 instance
Recovery test3MediumStatelessRestart instance
Real workload3Production-likeStatelessOptional

This produces much more useful engineering information than running one benchmark command and reporting a single requests-per-second number.

Production Best Practices

Keep MCP Instances Disposable

A stateless MCP server should ideally be safe to terminate and recreate.

Avoid storing important application state exclusively in process memory.

Put Durable State Behind the Service

Use an appropriate persistent or distributed system for application state when required.

Examples include:

  • Relational databases

  • Distributed caches

  • Object storage

  • External workflow stores

Monitor Tail Latency

Use p95 and p99 dashboards rather than relying exclusively on averages.

Correlate Requests

Stateless transport does not eliminate observability requirements.

Use distributed tracing and request correlation so that a tool invocation can be followed across:

Client
  ↓
Load Balancer
  ↓
MCP Server
  ↓
Database/API

Avoid Unnecessary Stateful Transport

The SDK supports stateful HTTP when an application needs session-specific behavior, but stateless mode is the default and is designed specifically for horizontally scalable HTTP deployments.

Test the Real Deployment Topology

If production uses Kubernetes, a cloud load balancer, an API gateway, or a WAF, benchmark through that infrastructure.

A localhost benchmark cannot reproduce all of those variables.

Troubleshooting

Requests Keep Going to One Instance

Check the load-balancer configuration.

Remove session affinity for the stateless experiment and verify that the upstream pool contains all instances.

Stateless Requests Fail After Scale-Out

Look for application state stored in:

static

or:

IMemoryCache

or another process-local store.

Those mechanisms can work for local caching, but they should not be treated as durable shared state across instances.

Memory Continues Increasing

Check whether the application is retaining:

  • Tool results

  • Request objects

  • Large payloads

  • Logging buffers

  • Application-level caches

  • Unbounded collections

Stateless transport does not prevent application-level memory leaks.

p99 Latency Increases While Average Latency Looks Fine

Investigate:

  • Thread-pool starvation

  • GC pauses

  • Connection-pool exhaustion

  • Load-balancer queueing

  • Downstream API latency

  • Database contention

Tail latency is often where capacity problems become visible first.

Frequently Asked Questions

Does stateless MCP eliminate the need for a load balancer?

No. It makes load balancing simpler because protocol-level session affinity is no longer required for stateless HTTP. You can still use a load balancer for distribution, TLS termination, health checks, and other infrastructure responsibilities.

Does stateless MCP mean the application cannot maintain state?

No. Application state can still exist in databases, distributed caches, or other durable systems. The key difference is that the MCP transport itself does not maintain a session between requests.

Can every MCP server use stateless mode?

No. Servers that require session-scoped transport state or unsolicited server-to-client messages may need stateful mode. The C# SDK documentation explicitly identifies these as reasons to use stateful HTTP.

Is stateless mode automatically faster?

Not necessarily. Its primary benefit is architectural: simpler horizontal scaling and reduced dependence on transport sessions. Actual latency and throughput must be measured for the workload.

Should I benchmark the MCP server directly or through the load balancer?

For a production scalability study, do both.

A direct-server test helps isolate application performance. A load-balanced test measures the behavior users will actually experience.

Conclusion

Stateless MCP changes the scalability model of HTTP-based MCP servers.

With the MCP C# SDK 2.0, stateless HTTP is the default. The server does not need to maintain a transport session for each client, and requests can be distributed across instances using ordinary HTTP infrastructure. This removes a major source of complexity for horizontally scaled deployments.

But the architectural benefit should not be confused with a guaranteed performance improvement.

A meaningful benchmark should measure throughput, p95 and p99 latency, CPU, memory, error rate, instance distribution, scale-out efficiency, and failure recovery. It should also distinguish transport state from application state.

The strongest production experiment is therefore not simply:

Stateless vs Stateful

It is:

1 instance
    ↓
2 instances
    ↓
3 instances
    ↓
5 instances
    ↓
Instance failure
    ↓
Recovery
    ↓
Production-like workload

Run the same workload through the same infrastructure, collect the same metrics, and publish the measured results.

That approach turns MCP statelessness from an architectural claim into something an engineering team can actually validate before adopting it in production.