I have been working with microservices for 7+ years.
You can't build a quality microservices system without these 3 things:
- Observability
- Integration tests
- Load tests
Many teams skip load tests.
They think it takes too much time, or they are not important.
This is a mistake. Without load tests, problems often show up in production when it is too late and too costly to fix them.
In microservices, your system connects to many parts:
- Databases
- Distributed caches
- Message queues
- External APIs
- Other services
How do you decide if your service should use MS SQL Server, PostgreSQL, or MongoDB?
Should services talk through a REST API (synchronous communication) or use a message queue (asynchronous)?
Load tests will give you clear answers.
They show how much traffic your system can handle before it slows down or fails.
They reveal slow parts of your system and help compare different design choices.
They show real latency numbers, help you check timeouts, retries, and error handling under stress.
They also give you data for capacity planning so you know when to scale.
And when you run them regularly, you can spot performance regressions early.
They also help you compare different setups under the same conditions, such as MS SQL Server vs PostgreSQL vs MongoDB.
You can test REST API calls against message queue events to see which communication method works better for your case.
One of the best solutions I have found for load tests in .NET is NBomber.
Today I want to show you how easily you can create load tests for microservices with NBomber.
In this post, we will explore:
- Getting Started With NBomber
- Creating Load Tests with NBomber
- Running Load Tests in Parallel
- Configuring NBomber with JSON Files
- Creating Reports with NBomber
Let's dive in.
P.S.: I publish all these blogs on my own website: Read original post here
Subscribe to my newsletter to improve your .NET skills.
Download the source code for this newsletter for free.
Getting Started With NBomber
NBomber is a modern and flexible load-testing framework designed to test any system regardless of a protocol or a semantic model (Pull/Push).
NBomber lets you define load test scenarios in plain C# or F# code.
One of its key advantages over other tools is the ability to use your favorite language and IDE (with a debugger) to model realistic load tests.
As your load test codebase grows, the benefits of using a type-safe programming language become increasingly important for maintaining and evolving your tests.
NBomber is protocol-agnostic by design. Unlike many tools, it doesn't depend on external packages for specific protocols, which makes it flexible enough to test anything from HTTP and WebSockets to gRPC, Kafka, NoSQL databases, or custom protocols.
In addition, NBomber provides a rich ecosystem of open-source plugins and extensions - covering protocols, real-time reporting, monitoring, and more.
This makes it well-suited for adoption across teams and organization-wide testing initiatives.
NBomber offers the following features:
- Protocol-agnostic – you can test HTTP, gRPC, WebSockets, databases, message brokers, or anything else you can call from .NET.
- Cloud-agnostic – you can run and replicate your test on any cloud. No need to depend on a specific cloud UI, just build your app, deploy, and get results.
- Developer-friendly – you write tests in C#/F# using your IDE, with type safety and full debugging support.
- HTML Reports – built-in HTML reports with custom metrics
- Real-time reporting - Stores load test metrics in many different observability systems (DataDog, InfluxDB, TimeScaleDB, Grafana, NBomber Studio)
- Plugins support - you can add your own plugins (Converter, WebSockets, AMQP, MQTT) and data sinks
- Integration with xUnit/NUnit – you can run load tests as part of your CI/CD pipeline (xUnit Example).
- Kubernetes/Cluster mode – run distributed tests across multiple nodes.
You can find system requirements for using NBomber - here.
Creating Scenarios
To create load tests, you need to define a scenario.
Scenario represents some user behaviour you need to test, for example:
- User logs in
- User loads profile page
- User creates an order
You can create a scenario with the Scenario.Create
method:
var scenario = Scenario.Create("order scenario", async context =>
{
await Login();
await LoadProfile();
await CreateOrder();
return Response.Ok();
})
.WithLoadSimulations(
Simulation.Inject(rate: 10,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(30))
);
When you run this scenario, NBomber will measure the whole scenario execution.
At the end of execution, NBomber will print the scenario's statistics result.
Each load test consists of the following phases:
- Init
- Warm-up
- Bombing (testing)
- Clean
Scenario Init
This method should be used to initialize the Scenario and all its dependencies.
You can use it to prepare your target system, populate the database, or read and apply the JSON configuration for your scenario.
var scenario = Scenario.Create("order scenario", async context =>
{
await Login();
await LoadProfile();
await CreateOrder();
return Response.Ok();
})
.WithInit(async context =>
{
await SeedDatabase();
})
.WithLoadSimulations(
Simulation.Inject(rate: 10,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(30))
);
Scenario Clean
This method should be used to clean the scenario's resources after the test finishes.
var scenario = Scenario.Create("order scenario", async context =>
{
await Login();
await LoadProfile();
await CreateOrder();
return Response.Ok();
})
.WithClean(async context =>
{
await CleanDatabase();
});
Let's explore how we can write load tests for a real-world microservices system.
Creating Load Tests with NBomber
I have built a microservices application with three services:
- UserService — responsible for user authentication and JWT issuing
- ShipmentService — for creating shipments
- StockService — for checking and updating inventory
All services are placed behind an API Gateway (YARP) and store data in PostgreSQL.
Here is how the services interact with each other when creating a shipment:
"Create Shipment" use case has the following flow:
- ShipmentService sends a REST HTTP request to StockService and checks whether there are enough products to create a shipment
- If enough stocks — creates a shipment in the database
- Sends a "ShipmentCreatedEvent" to a message queue
- StockService — subscribes to the event and updates the inventory
Creating First Load Scenario
Let's create our first scenario with NBomber that consists of 2 steps:
- User logs in
- User loads his profile
With NBomber, we can test how UserService performs when many users log into their profiles at the same time.
Follow these simple steps to create your load tests:
Step 1:
Create a .NET Console project.
Step 2:
Add the following NuGet packages:
dotnet add package NBomber
dotnet add package NBomber.Http
To work with HTTP, NBomber provides NBomber.Http plugin for the native .NET HttpClient.
This plugin offers useful extensions that simplify creating and sending requests, receiving responses, and tracking data transfer and status codes.
Step 3:
Create a test scenario with NBomber.
You can choose 2 options:
- You can run all the needed steps as a whole and NBomber will measure the performance of the complete flow:
var httpClient = Http.CreateDefaultClient();
var scenario = Scenario.Create("user_login_and_get_profile_scenario", async context =>
{
await Login();
await GetProfile();
})
.WithoutWarmUp()
.WithLoadSimulations(
Simulation.Inject(rate: 10,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(30))
);
- Or you can move each method into separate steps and NBomber will measure them separately:
var httpClient = Http.CreateDefaultClient();
var scenario = Scenario.Create("user_login_and_get_profile_scenario",
async context =>
{
var step1 = await Step.Run("login", context, () => LoginStep(httpClient));
var token = step1.Payload.Value;
var step2 = await Step.Run("get_profile", context, () => GetProfileStep(httpClient, token));
return step2;
})
.WithoutWarmUp()
.WithLoadSimulations(
Simulation.Inject(rate: 10,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(30))
);
Here is how you can send an HTTP request with NBomber:
var loginRequest = Http.CreateRequest("POST", "http://localhost:5000/api/users/login")
.WithJsonBody(new { Email = "admin@test.com", Password = "Test1234!"});
// Send HTTP request with NBomber
var loginResponse = await Http.Send(httpClient, loginRequest);
Here is the complete implementation of each step:
private async Task<Response<string>> LoginStep(
HttpClient httpClient,
IScenarioContext context)
{
// Create HTTP request
var loginRequest = Http.CreateRequest("POST", "http://localhost:5000/api/users/login")
.WithJsonBody(new { Email = "admin@test.com", Password = "Test1234!"});
var loginResponse = await Http.Send(httpClient, loginRequest);
var responseBody = await loginResponse.Payload.Value.Content.ReadFromJsonAsync<LoginUserResponse>();
return responseBody is null
? Response.Fail<string>(message: "No JWT token available")
: Response.Ok(payload: responseBody.Token);
}
private async Task<Response<string>> GetProfileStep(
HttpClient httpClient,
IScenarioContext context)
{
var profileRequest = Http.CreateRequest("GET", "http://localhost:5000/api/users/profile")
.WithHeader("Accept", "application/json")
.WithHeader("Authorization", $"Bearer {token}");
var profileResponse = await Http.Send(httpClient, profileRequest);
var responseBody = await profileResponse.Payload.Value.Content.ReadFromJsonAsync<UserResponse>();
return responseBody is null
? Response.Fail<string>(message: "User profile not found")
: Response.Ok<string>();
}
Each step and scenario should return either Response.Ok
or Response.Fail
.
Note that LoginStep
step returns the JWT token that is used in the GetProfileStep
.
You can also share data between steps with ScenarioContext
, that represents the execution context of the currently running Scenario.
You can use ScenarioContext
to share data between steps.
It provides functionality to log particular events, get information about the test, thread ID, scenario copy/instance number, etc.
Also, it provides the option to stop all or particular scenarios manually.
Step 4:
Run the scenario in the Release mode:
NBomberRunner
.RegisterScenarios(scenario)
.Run(args);
We are running a load test for 30 seconds, and after it finishes, we will get the following results in the Console:
This 30-second test ran the user_login_and_get_profile_scenario
at 20 requests per second, completing 600 requests with zero failures.
You can see one login request that took over 1 second - it's the first request as we have specified WithoutWarmUp
when creating a scenario.
Configuring Load Simulation
NBomber gives you several ways to control how virtual users are added during a test.
This is called Load Simulation.
You can configure:
- Open Model: new virtual users arrive at a fixed rate, no matter how many are already running. This is good for testing systems that face a steady stream of incoming requests.
- Closed Model: the number of active virtual users stays constant. New users only start once others have finished. This is useful for testing complete user journeys with predictable concurrency.
You can also combine both models in the same test to simulate different traffic patterns.
For most end-to-end user flows, the Closed Model is a good starting point.
If you are unsure, start here and adjust based on results.
Whatever model you choose, ramp up the load gradually.
This avoids overwhelming your system instantly and helps you see when performance starts to degrade.
Best Practices When Using HttpClient in Load Tests
When testing HTTP-based services, follow these best practices:
- Reuse your
HttpClient
– create one HttpClient per scenario, not per request. This avoids unnecessary TCP connections and reduces overhead. - Per-user
HttpClient
(if needed) – if each virtual user needs its own cookies or session state, attach a dedicated HttpClient to the scenario instance using context.ScenarioInstanceData. - Do not dispose too often – frequent disposal can cause socket exhaustion and skew results. It's better not to dispose
HttpClient
during the load test.
These practices will keep your load tests reliable and ensure you are testing the service's limits and not hitting issues in your test code.
Creating Second Load Scenario
Let's create our 2nd scenario that consists of 2 steps:
- User logs in
- User creates a shipment
var httpClient = Http.CreateDefaultClient();
var scenario = Scenario.Create("user_login_and_create_shipment_scenario",
async context =>
{
var step1 = await Step.Run("login", context, () => LoginStep(httpClient));
var token = step1.Payload.Value;
var step2 = await Step.Run("create_shipment", context, () => CreateShipmentStep(httpClient, token));
return step2;
})
.WithLoadSimulations(
// ramp up the injection rate from 0 to 50 copies
// injection interval: 5 seconds
// duration: 30 seconds (it executes from [00:00:00] to [00:00:30])
Simulation.RampingInject(rate: 50,
interval: TimeSpan.FromSeconds(5),
during: TimeSpan.FromSeconds(30)),
// keeps injecting 50 copies per 1 second
// injection interval: 5 seconds
// duration: 30 seconds (it executes from [00:00:30] to [00:01:00])
Simulation.Inject(rate: 50,
interval: TimeSpan.FromSeconds(5),
during: TimeSpan.FromSeconds(30)),
// ramp down the injection rate from 50 to 0 copies
// injection interval: 5 seconds
// duration: 30 seconds (it executes from [00:01:00] to [00:01:30])
Simulation.RampingInject(rate: 0,
interval: TimeSpan.FromSeconds(5),
during: TimeSpan.FromSeconds(30))
);
The CreateShipmentStep
implementation is as follows:
private async Task<Response<string>> CreateShipmentStep(
HttpClient httpClient,
IScenarioContext context)
{
var request = GetCreateShipmentRequest();
// Send HTTP request with NBomber
var createShipmentRequest = Http.CreateRequest("POST", "http://localhost:5000/api/shipments")
.WithHeader("Content-Type", "application/json")
.WithHeader("Authorization", $"Bearer {token}")
.WithJsonBody(request);
await Http.Send(httpClient, createShipmentRequest);
return Response.Ok(payload: orderId);
}
Let's run the test:
This test verifies that our complete flow works under load:
- User logs in
- User initiates shipment creation
- ShipmentService sends a REST HTTP request to StockService and checks whether there are enough products to create a shipment
- If enough stocks — creates a shipment in the database
- Sends a "ShipmentCreatedEvent" to a message queue
- StockService — subscribes to the event and updates the inventory
Running Load Tests in Parallel
In real systems, traffic is rarely a single type of request.
Some users might be browsing profiles, others might be creating orders, and both happen at the same time.
NBomber lets you model this by running multiple scenarios in parallel.
For example, if your site usually has 100 concurrent users, you might simulate:
- 70 users visiting the home page
- 30 users creating orders
You do this by creating two scenarios, each with its own throughput or virtual user count, and then registering them to run together.
This way, your load test reflects realistic traffic patterns instead of a single workload.
We can combine our two previous scenarios and run them side-by-side in parallel:
var scenario1 = new UserLoginAndProfileScenario().CreateScenario();
var scenario2 = new UserLoginAndCreateShipmentScenario().CreateScenario();
NBomberRunner
.RegisterScenarios(scenario1, scenario2)
.Run(args);
public class UserLoginAndProfileScenario
{
public ScenarioProps CreateScenario()
{
var httpClient = Http.CreateDefaultClient();
var scenario = Scenario.Create("user_login_and_get_profile_scenario",
async context =>
{
// Step 1: LoginStep
// Step 2: GetProfileStep
// Code remains the same from the previous examples
})
.WithoutWarmUp()
.WithLoadSimulations(
Simulation.Inject(rate: 20,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(30))
);
return scenario;
}
}
public class UserLoginAndCreateShipmentScenario
{
public ScenarioProps CreateScenario()
{
var httpClient = Http.CreateDefaultClient();
var scenario = Scenario.Create("user_login_and_create_shipment_scenario",
async context =>
{
// Step 1: LoginStep
// Step 2: CreateShipmentStep
// Code remains the same from the previous examples
})
.WithLoadSimulations(
Simulation.RampingInject(rate: 50,
interval: TimeSpan.FromSeconds(5),
during: TimeSpan.FromSeconds(30)),
Simulation.Inject(rate: 50,
interval: TimeSpan.FromSeconds(5),
during: TimeSpan.FromSeconds(30)),
Simulation.RampingInject(rate: 0,
interval: TimeSpan.FromSeconds(5),
during: TimeSpan.FromSeconds(30))
);
return scenario;
}
}
For more advanced setup, see the documentation.
Configuring NBomber with JSON Files
NBomber supports configuration through JSON files, making it easy to:
- Run the same test under different environments
- Adjust load patterns without changing code
- Keep versioned test profiles in source control
NBomber supports two JSON config types:
- JSON Config - overrides scenario execution settings like
LoadSimulations
,WarmUpDuration
, andTargetScenarios
. - JSON Infrastructure Config - overrides infrastructure settings like loggers, reporting sinks, and worker plugins.
Note: If a value is set in both code and JSON, the JSON value takes priority.
You can load JSON in three ways:
// From JSON file
NBomberRunner
.RegisterScenarios(scenario)
.LoadConfig("config.json")
.Run(args);
// Providing JSON directly
NBomberRunner
.RegisterScenarios(scenario)
.LoadConfig("{ PLAIN JSON }")
.Run(args);
// Providing from an URL
NBomberRunner
.RegisterScenarios(scenario)
.LoadConfig("https://my-test-host.com/config.json")
.Run(args);
NBomber also supports CLI arguments for loading JSON Config.
dotnet MyLoadTest.dll --config=config.json
Configuring Load Simulation
You can fully define Load Simulation in JSON, including multiple stages:
{
"LoadSimulationsSettings": [
{ "RampingInject": [50, "00:00:01", "00:00:30"] },
{ "Inject": [50, "00:00:01", "00:01:00"] },
{ "RampingInject": [0, "00:00:01", "00:00:30"] }
]
}
This example ramps up to 50 RPS, holds steady, then ramps down.
Configuring Thresholds
You can set Thresholds settings in JSON, so tests fail automatically if performance drops below your limits:
{
"ThresholdSettings": [
{ "OkRequest": "RPS >= 30" },
{ "OkRequest": "Percent > 90" }
]
}
Thresholds can also be checked in code after the tests finish:
var scenario = new UserLoginAndCreateShipmentScenario().CreateScenario();
var result = NBomberRunner
.RegisterScenarios(scenario)
.Run(args);
// Get the final stats to check against our thresholds.
var stats = result.ScenarioStats.Get("user_login_and_create_shipment_scenario");
Assert.True(stats.Fail.Request.Percent <= 5);
Custom Metrics
NBomber tracks built-in metrics (CPU, RAM, data transfer), but you can also create custom metrics for business or technical KPIs.
Two common types are:
- Counter – tracks a running total (e.g., total successful logins).
- Gauge – tracks the latest value (e.g., current memory usage).
Here is how you can define custom metrics in your code:
// define custom metrics
var counter = Metric.CreateCounter("my-counter", unitOfMeasure: "MB");
var gauge = Metric.CreateGauge("my-gauge", unitOfMeasure: "KB");
var scenario = Scenario.Create("scenario", async context =>
{
await Task.Delay(500);
counter.Add(1); // tracks a value that may increase or decrease over time
gauge.Set(6.5); // set the current value of the metric
return Response.Ok();
})
.WithInit(ctx =>
{
// register custom metrics
ctx.RegisterMetric(counter);
ctx.RegisterMetric(gauge);
return Task.CompletedTask;
});
var stats = NBomberRunner
.RegisterScenarios(scenario)
.Run(args);
// We can retrieve the final metric values for further assertions
var counterValue = stats.Metrics.Counters.Find("my-counter").Value;
var gaugeValue = stats.Metrics.Gauges.Find("my-gauge").Value;
You can also set thresholds on custom metrics:
// define custom metrics
var counter = Metric.CreateCounter("my-counter", unitOfMeasure: "MB");
var gauge = Metric.CreateGauge("my-gauge", unitOfMeasure: "KB");
var scenario = Scenario.Create("scenario", async context =>
{
...
})
.WithInit(ctx =>
{
// register custom metrics
ctx.RegisterMetric(counter);
ctx.RegisterMetric(gauge);
return Task.CompletedTask;
})
.WithThresholds(
Threshold.Create(metric => metric.Counters.Get("my-counter").Value < 1000),
Threshold.Create(metric => metric.Gauges.Get("my-gauge").Value >= 6.5)
);
Creating Reports with NBomber
You can generate reports with NBomber.
They are crucial for understanding how your application performs under load.
This is an example of an HTML report that you can interact with.
Reports provide detailed insights into various performance metrics, helping you identify bottlenecks, optimize performance, and ensure a smooth user experience.
NBomber can generate reports in the following formats:
- HTML report
- MD report
- CSV report
- TXT report
You can configure report formats using the WithReportFormats
method:
NBomberRunner
.RegisterScenarios(scenario)
.WithReportFormats(
ReportFormat.Csv, ReportFormat.Html,
ReportFormat.Md, ReportFormat.Txt
)
.Run(args);
You can also configure the report folder and a file name:
NBomberRunner
.RegisterScenarios(scenario)
.WithReportFolder("reports")
.WithReportFileName("shipment-reports")
.Run(args);
NBomber also allows you to retrieve the contents of the generated reports.
It's useful when you need to upload them to a custom destination (remote storage):
var result = NBomberRunner
.RegisterScenarios(scenario)
.Run(args);
var htmlReport = result.ReportFiles.First(x => x.ReportFormat == ReportFormat.Html);
var filePath = htmlReport.FilePath; // file path
var html = htmlReport.ReportContent; // string HTML content
// You can upload the content to a remote storage
Here is what the HTML report looks like when we run two scenarios in parallel:
In the "Scenarios" tab, you can view all the scenarios you ran during a testing session.
Summary
NBomber is a powerful and flexible load-testing tool for .NET.
It lets you create realistic user scenarios, run them in parallel, and measure performance at every step.
You can simulate HTTP, gRPC, WebSockets, databases, and message queues like RabbitMQ in the same test.
Its JSON configuration, runtime thresholds, and custom metrics make it easy to adapt tests.
The built-in reports show latency percentiles, error rates, and system metrics so you can spot problems fast.
With NBomber, you can validate system limits, compare technology choices, and find bottlenecks before they reach production.
The biggest advantage of NBomber is that you can write load tests directly in C# or F#.
This means you can reuse your existing application code, helpers, and libraries inside your tests.
You don't need to learn JavaScript or custom scripts for creating tests.
NBomber is free for personal use.
Using NBomber in an organization requires a paid license, learn more here.
NBomber's pricing is very affordable because the license covers the entire organization.
A single license can be shared across all teams, so there's no need to manage individual developer seats — one license works for the whole company.
You can check the Pricing info here.
Start building your load tests today with NBomber and find issues before they reach production:
- I highly recommend starting with the Hello World tutorial.
- Then please read this article about Load Testing Microservices. It will provide a basic foundation on how to cover your system with isolated and end-to-end (E2E) Load testing.
- After that, you're ready to explore their collection of demo examples
Many thanks to NBomber for sponsoring this blog post.
P.S.: I publish all these blogs on my own website: Read original post here
Subscribe to my newsletter to improve your .NET skills.
Download the source code for this newsletter for free.
Top comments (0)