Load Testing Microservices With C# and NBomber (.NET 10)
A beginner-friendly .NET 10 guide to load testing microservices with NBomber: write scenarios in plain C#, ramp up virtual users, and read the HTML report.
The railway ticket counter
Picture a small railway station in your town. There is one ticket counter and one clerk. Early in the morning, only a few people come. The clerk hands out tickets quickly, and nobody waits.
Then a festival day arrives. Suddenly hundreds of people show up at the same time. The line grows out of the door. The clerk starts to sweat. Some people wait an hour. A few give up and go home without a ticket.
The station master wants to avoid this. So before the festival, she runs a practice drill. She asks volunteers to pretend to be a big crowd and line up at the counter. She watches with a stopwatch. How long does each person wait? Does the clerk make mistakes when rushed? Does anyone leave without a ticket?
That practice drill is exactly what load testing does for your software. Your microservice is the ticket counter. Real users are the crowd. NBomber is the friend who organises a pretend crowd of "virtual users" and times everything for you, so you learn the truth in a safe drill instead of on the real festival day.
This guide shows you how to run that drill with C# and NBomber, step by step, in plain words.
What is NBomber?
NBomber is a load testing tool for .NET. The special thing about it is that you write your test in plain C#. You do not need to learn a new scripting language. If you can write a small console app, you can write an NBomber test.
You describe a scenario (what one user does), tell NBomber how many users to pretend to be, and press run. NBomber sends all those requests, measures everything, and gives you a tidy HTML report at the end.
The load testing drill
Steps
Write scenario
Describe one user's actions in C#
Set the load
Choose how many virtual users
Run NBomber
It fires the requests and times them
Read report
See speed, errors, and limits
A few words before code
Let us learn three small words. They appear everywhere in NBomber, so it helps to know them early.
| Word | What it means | Railway analogy |
|---|---|---|
| Step | One single action, like one HTTP call | One person asking for one ticket |
| Scenario | A workflow made of one or more steps | One person's full visit to the counter |
| Load simulation | The rule for how many virtual users run | How fast the crowd arrives at the counter |
A step is the smallest unit. A scenario groups steps into a story, like "log in, then search, then buy". A load simulation decides whether two users arrive per second or two hundred.
Setting up the project
NBomber tests live in their own little console project. Keep them separate from your real service. Here is how to create the project and add the packages.
// Run these commands in your terminal.
// 1. Create a new console project for the load test.
dotnet new console -n LoadTests -lang "C#"
cd LoadTests
// 2. Add the NBomber core package.
dotnet add package NBomber
// 3. Add the HTTP helper package (makes HTTP steps easy).
dotnet add package NBomber.HttpThe first package, NBomber, is the engine. The second, NBomber.Http, gives you friendly helpers for the common case of calling an HTTP API. You can test other things too, but HTTP is where most people start.
Your first scenario
Let us test a simple endpoint. Imagine a products microservice with GET /products. We want to know how it behaves when many users ask for the product list at once.
Here is a full, working example. Read the comments; they explain each line.
using NBomber.CSharp;
using NBomber.Http.CSharp;
// One HttpClient is shared by all virtual users. This is on purpose.
using var httpClient = new HttpClient();
// A scenario describes what one virtual user does.
var scenario = Scenario.Create("get_products", async context =>
{
// A step is one action. Here, one GET request.
var request = Http.CreateRequest("GET", "https://localhost:5001/products")
.WithHeader("Accept", "application/json");
// Send the request and let NBomber measure the timing and status.
var response = await Http.Send(httpClient, request);
return response;
})
.WithoutWarmUp()
.WithLoadSimulations(
// Keep a steady rate of 50 virtual users per second for 30 seconds.
Simulation.Inject(rate: 50,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(30))
);
// Hand the scenario to NBomber and run it.
NBomberRunner
.RegisterScenarios(scenario)
.Run();Run it with dotnet run. NBomber prints a live table in your terminal while it works, then writes an HTML report into a reports folder. We will read that report soon.
Understanding load simulations
The load simulation is the heart of the test. It is the rule that decides how the pretend crowd arrives. NBomber gives you a few simple rules, and you pick the one that matches the question you are asking.
| Simulation | What it does | When to use it |
|---|---|---|
Inject | Adds users at a steady rate per second | Normal, expected traffic (load test) |
RampingInject | Grows the rate from low to high over time | Warming up, or a slow climb |
KeepConstant | Keeps a fixed number of users busy at once | When you care about concurrent users |
RampingConstant | Slowly changes the number of busy users | Gentle ramp of concurrency |
The difference between Inject and KeepConstant confuses many beginners, so let us be clear. Inject is about arrival rate: "50 new people walk in every second, whether or not the last ones have finished." KeepConstant is about concurrency: "always keep exactly 50 people at the counter; when one leaves, the next steps up."
For testing a public API, Inject is usually closer to real life. Real users do not wait for each other.
From load test to stress test
The drill so far was a load test: a steady, expected crowd. A stress test asks a different question. "How far can we go before the service falls over?"
To do that, you climb the rate higher and higher. NBomber makes this easy by listing several simulations in order. Each one runs after the last.
var stressScenario = Scenario.Create("stress_products", async context =>
{
var request = Http.CreateRequest("GET", "https://localhost:5001/products");
var response = await Http.Send(httpClient, request);
return response;
})
.WithLoadSimulations(
// Phase 1: warm up gently to 50 per second over 20 seconds.
Simulation.RampingInject(rate: 50,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(20)),
// Phase 2: climb hard to 500 per second over 40 seconds.
Simulation.RampingInject(rate: 500,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(40)),
// Phase 3: hold the heavy load steady for 30 seconds.
Simulation.Inject(rate: 500,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(30))
);Watch the report from this run carefully. At some rate, you will see the response times shoot up and errors start to appear. That rate is your breaking point. Now you know your true limit, found safely in a drill.
Stages of a stress test
Steps
Warm up
Gentle ramp to a normal rate
Climb hard
Push the rate far higher
Hold heavy
Keep the heavy load steady
Spot the break
See where latency and errors jump
Testing a microservice on its own
Microservices talk to each other. A checkout service might call a products service, a pricing service, and a payments service. When the whole chain is slow, it is hard to know which link is the weak one.
The trick is to test each service on its own first. Point NBomber straight at one service. Give it the data it needs. Now its numbers belong only to that service, with no noise from its neighbours.
Once each service passes its own drill, run a bigger scenario that walks the full path a real user takes. This second drill catches problems that only appear when services queue up behind each other.
A scenario with several steps
Real users rarely do just one thing. They log in, then look around, then act. You can put several steps inside one scenario and measure each step on its own. This tells you which step is the slow one.
var checkoutScenario = Scenario.Create("checkout_flow", async context =>
{
// Step 1: get the product list.
var browse = await Step.Run("browse", context, async () =>
{
var req = Http.CreateRequest("GET", "https://localhost:5001/products");
return await Http.Send(httpClient, req);
});
// Step 2: add an item to the cart.
var addToCart = await Step.Run("add_to_cart", context, async () =>
{
var req = Http.CreateRequest("POST", "https://localhost:5001/cart")
.WithHeader("Content-Type", "application/json")
.WithBody(new StringContent("{ \"productId\": 1, \"qty\": 2 }"));
return await Http.Send(httpClient, req);
});
// Step 3: place the order.
var order = await Step.Run("checkout", context, async () =>
{
var req = Http.CreateRequest("POST", "https://localhost:5001/orders");
return await Http.Send(httpClient, req);
});
return Response.Ok();
})
.WithLoadSimulations(
Simulation.Inject(rate: 20,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(60))
);In the report, each named step (browse, add_to_cart, checkout) gets its own row. If checkout is much slower than the others, you have found the weak link without guessing.
Note one detail about routes. If your endpoint uses a path parameter, write it carefully. For example a product detail route looks like GET /products/{id}, and in code you build the real URL by putting the actual id in, such as https://localhost:5001/products/42.
Reading the report
When the run finishes, NBomber writes an HTML report. Open it in your browser. Do not worry about every number. Focus on these few.
| Metric | What it tells you | What "good" looks like |
|---|---|---|
| Request count | How many requests were sent in total | Matches your expected load |
| Fail count | How many requests failed | As close to zero as possible |
| Mean (ms) | The average response time | Low and stable |
| p95 / p99 (ms) | The slowest 5% and 1% of responses | Not wildly higher than the mean |
| RPS | Requests handled per second | Meets your target throughput |
The percentile numbers (p95, p99) are the most honest. The average can look fine while a few unlucky users wait a very long time. The p99 tells you what your slowest users actually feel. A good service keeps even its slow tail under control.
Tips for honest results
A drill only helps if it is realistic. Here are simple habits that keep your numbers trustworthy.
Run the load test from a machine close to the service, but not the same machine. If NBomber and the service fight for the same CPU, your numbers lie.
Use a build that matches production. Test the Release build, not Debug, and turn off the debugger. A Debug build can be far slower and will scare you for no reason.
Use real-looking data. If every virtual user asks for the same product id, your database cache makes the service look faster than it really is. Vary the data so the test feels like a real crowd.
Warm up before you measure, or use WithoutWarmUp only when you understand why. The very first requests are often slow because caches and connections are still waking up. A short warm-up gives you fairer steady-state numbers.
A note on licensing
NBomber has a free, open-source core. The NBomber and NBomber.Http NuGet packages let you write scenarios, ramp virtual users, and get an HTML report without paying. Some advanced features, such as cloud-hosted reporting and running a single test across a cluster of many machines, belong to paid plans. For learning and for most single-machine drills, the free packages are all you need. This is a different situation from some other .NET libraries, like MediatR and MassTransit, which have recently moved their main packages to a commercial licence. Always check the current licence of any library before you depend on it at work.
Quick recap
- Load testing is a safe drill. NBomber pretends to be a big crowd so you learn your limits before real users arrive.
- You write tests in plain C#. No new scripting language to learn.
- A step is one action, a scenario is a workflow of steps, and a load simulation is the rule for how the crowd arrives.
- Use
Injectfor a steady arrival rate (load test) andRampingInjectto climb the rate and find the breaking point (stress test). - Test each microservice on its own first, then test the full user path to catch problems that only appear in the chain.
- In the report, watch fail count and p95 / p99, not just the average. The slow tail is what real users feel.
- Test the
Releasebuild, use real-looking data, and run the test from a separate machine for honest numbers. - The NBomber core is free; some cloud and cluster features are paid.
References and further reading
- NBomber on GitHub — official repository
- NBomber official site and documentation
- Hello World tutorial — NBomber docs
- Scenario — NBomber docs
- Step — NBomber docs
- HTTP protocol plugin — NBomber docs
- Load Testing Microservices — NBomber best practices
- Load Testing Microservices With C# and NBomber — antondevtips
Related Posts
ASP.NET Core Integration Testing Best Practices (.NET 10)
A friendly .NET 10 guide to ASP.NET Core integration testing: WebApplicationFactory, real databases with Testcontainers, clean test isolation, and CI tips.
How to Test API Integrations Using WireMock.Net in .NET 10
A beginner-friendly .NET 10 guide to testing API integrations with WireMock.Net: stub HTTP responses, simulate errors and delays, and write reliable tests.
.NET Aspire Integration Testing: Best Practices for Distributed Apps
Learn .NET Aspire integration testing the simple way. Start your whole app, wait for services, and test how they really work together.
Load Testing Kafka Pipelines With C# and NBomber in .NET 10
A beginner-friendly guide to load testing Kafka pipelines in .NET 10 with C# and NBomber: push messages, measure throughput, watch consumer lag, and read results.
YARP as an API Gateway in .NET: A Beginner's Guide
Learn how to use YARP as an API gateway in .NET 10. Routes, clusters, load balancing, health checks, auth, and transforms explained in simple, friendly steps.
Implementing an API Gateway for Microservices With YARP
Learn to build an API gateway for microservices with YARP in .NET 10. Routes, clusters, auth, rate limits, and transforms explained in simple steps.