NBomber: Load Testing for .NET Applications
NBomber is a load testing framework for .NET that lets you define scenarios and load shapes in C#, run them programmatically, and get structured statistics — making it natural to integrate load tests into a .NET project without learning a separate DSL.
Key Takeaways
- Scenarios are defined as C# code using Scenario.Create() with async step functions — no separate DSL to learn
- LoadSimulation controls the shape of load: constant users, ramp up/down, injection rate per second
- The HTTP plugin (NBomber.Http) removes boilerplate for HTTP load tests and provides per-endpoint stats
- NBomber outputs a detailed HTML report plus JSON/CSV for CI threshold checks
- Use NBomber when your team is .NET-first; use k6 or Locust when you need cross-team scripting or larger ecosystem
Every application has a performance profile that only shows up under load. A service that handles 10 requests per second flawlessly might fail at 100 — not because of a bug in any single request handler, but because of database connection pool exhaustion, thread starvation, or a cache that performs well when warm but not when stampeded cold. Load testing finds these problems before users do.
NBomber is a .NET-native load testing framework. You write load tests in C#, run them as console applications or as part of your test suite, and get structured statistics back. This post covers the full workflow: defining scenarios, configuring load shapes, using the HTTP plugin, reading output, and integrating with CI.
Installing NBomber
dotnet add package NBomber
dotnet add package NBomber.Http # for HTTP scenariosNBomber runs as a .NET console application. Create a new console project or add it to an existing test project.
Core Concepts
NBomber has three building blocks:
- Step — the unit of work. A function that executes one operation (one HTTP request, one database query) and returns a
Responseindicating success, failure, and how many data units were transferred. - Scenario — a named group of steps representing one user journey. A scenario knows how to execute its steps and tracks its own statistics.
- LoadSimulation — the traffic pattern: how many virtual users, ramping up or down, injecting at a fixed rate.
A Basic Scenario
using NBomber.Contracts;
using NBomber.CSharp;
var scenario = Scenario.Create("homepage_load", async context =>
{
// Simulate thinking time between requests
await Task.Delay(500);
using var client = new HttpClient();
var response = await client.GetAsync("https://api.example.com/health");
return response.IsSuccessStatusCode
? Response.Ok(statusCode: (int)response.StatusCode)
: Response.Fail(statusCode: (int)response.StatusCode,
error: $"Unexpected status: {response.StatusCode}");
})
.WithLoadSimulations(
Simulation.KeepConstant(copies: 10, during: TimeSpan.FromSeconds(30))
);
NBomberRunner
.RegisterScenarios(scenario)
.Run();This creates 10 concurrent virtual users, each running the step function in a loop for 30 seconds. Each iteration: wait 500ms, make a request, report the result.
Load Simulations
Load simulations define the shape of traffic. NBomber provides several strategies that cover most real-world load profiles.
// Keep a fixed number of concurrent users for a duration
Simulation.KeepConstant(copies: 50, during: TimeSpan.FromMinutes(2))
// Inject a fixed number of new users per second (open model)
Simulation.InjectPerSec(rate: 100, during: TimeSpan.FromMinutes(1))
// Ramp up the injection rate gradually
Simulation.RampingInject(
rate: 200,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromMinutes(5))
// Starts at 0 req/s, reaches 200 req/s over 5 minutes
// Ramp up concurrent users
Simulation.RampingConstant(copies: 100, during: TimeSpan.FromMinutes(3))
// Starts at 0 users, reaches 100 users over 3 minutes
// Combine simulations in a sequence (run each phase in order)
.WithLoadSimulations(
Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(30)), // warm up
Simulation.KeepConstant(copies: 50, during: TimeSpan.FromMinutes(5)), // steady state
Simulation.RampingConstant(copies: 0, during: TimeSpan.FromSeconds(30)) // ramp down
)The distinction between "closed model" (KeepConstant, RampingConstant) and "open model" (InjectPerSec, RampingInject) matters:
- Closed model: maintains N concurrent users. If a request takes a long time, the user waits before sending the next one. Total throughput is bounded by response time × user count.
- Open model: injects N new requests per second regardless of pending ones. Models real internet traffic more accurately but can overwhelm a service faster.
For most backend services, start with InjectPerSec because it models real traffic. Use KeepConstant when simulating a fixed user pool (like a websocket-connected app).
The HTTP Plugin
Writing HttpClient manually in every scenario is verbose. The NBomber.Http plugin provides an HTTP step factory that handles connection pooling, response parsing, and per-status-code statistics.
using NBomber.Http.CSharp;
// Create a shared HttpClient (reuse across steps — important for perf)
using var httpClient = new HttpClient();
var getProductsStep = HttpClientApi.Send(httpClient, context =>
{
return new HttpRequestMessage(HttpMethod.Get,
"https://api.example.com/products?page=1&limit=20");
});
var createOrderStep = HttpClientApi.Send(httpClient, context =>
{
var body = JsonSerializer.Serialize(new
{
productId = 42,
quantity = 1,
customerId = context.ScenarioInfo.ThreadId // vary by thread
});
return new HttpRequestMessage(HttpMethod.Post, "https://api.example.com/orders")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
Headers = { Authorization = new AuthenticationHeaderValue("Bearer", "token") }
};
});
var scenario = Scenario.Create("order_flow", async context =>
{
var getResult = await getProductsStep.Execute(context);
if (getResult.IsError) return getResult;
await Task.Delay(1000); // think time between steps
return await createOrderStep.Execute(context);
})
.WithLoadSimulations(
Simulation.InjectPerSec(rate: 20, during: TimeSpan.FromMinutes(3))
);Multiple Scenarios with Different Profiles
Real systems have mixed traffic — browse, search, checkout, and background polling all hitting the same API simultaneously. NBomber supports multiple scenarios running in parallel.
var browseScenario = Scenario.Create("browse", async context =>
{
// simulate casual browsing
await Task.Delay(Random.Shared.Next(500, 2000));
return await browseStep.Execute(context);
})
.WithLoadSimulations(
Simulation.KeepConstant(copies: 100, during: TimeSpan.FromMinutes(10))
);
var checkoutScenario = Scenario.Create("checkout", async context =>
{
// simulate checkout flow
var addResult = await addToCartStep.Execute(context);
if (addResult.IsError) return addResult;
await Task.Delay(3000); // user reviewing cart
return await checkoutStep.Execute(context);
})
.WithLoadSimulations(
Simulation.InjectPerSec(rate: 5, during: TimeSpan.FromMinutes(10))
);
NBomberRunner
.RegisterScenarios(browseScenario, checkoutScenario)
.Run();Both scenarios run simultaneously. Statistics are tracked per scenario, so you can see how checkout latency degrades under browse load.
Setup and Teardown
For tests that need authentication, database seeding, or test user creation, NBomber provides global and per-scenario setup/teardown hooks.
var scenario = Scenario.Create("authenticated_api", async context =>
{
// Use data set up in InitAsync
var token = context.GlobalCustomSettings.Get<string>("auth_token");
return await authenticatedStep.Execute(context, token);
})
.WithInit(async context =>
{
// Called once before the scenario starts
var token = await authService.GetTokenAsync("loadtest@example.com", "password");
context.GlobalCustomSettings.Set("auth_token", token);
})
.WithClean(async context =>
{
// Called once after the scenario ends
await authService.RevokeTokenAsync(
context.GlobalCustomSettings.Get<string>("auth_token"));
})
.WithLoadSimulations(
Simulation.InjectPerSec(rate: 50, during: TimeSpan.FromMinutes(5))
);Reading the Output
After a run, NBomber prints a statistics table and generates an HTML report. Understanding the columns:
+------------------+----------+--------+--------+------+------+------+------+-------+
| scenario | step | ok | failed | RPS | p50 | p75 | p95 | p99 |
+------------------+----------+--------+--------+------+------+------+------+-------+
| browse | browse | 48,240 | 12 | 160.8| 45ms | 62ms |120ms |380ms |
| checkout | add_cart | 2,891 | 3 | 9.6 | 88ms |102ms |210ms |450ms |
| checkout | checkout | 2,889 | 3 | 9.6 |112ms |145ms |280ms |620ms |
+------------------+----------+--------+--------+------+------+------+------+-------+Key metrics:
- ok / failed: total successful and failed requests over the run
- RPS: requests per second (throughput)
- p50/p75/p95/p99: latency percentiles. p99 is critical — 1% of your users experience this latency or worse. If p99 is 3 seconds and your SLO is 1 second, you have a problem even if p95 looks fine.
A common mistake is optimizing for p50 (median) while ignoring p99. In a 1,000 req/s system, 10 requests per second are hitting your worst-case latency. Those are real users.
Integrating with CI
NBomber supports threshold assertions that fail the process with a non-zero exit code when performance degrades, making it usable in CI pipelines.
NBomberRunner
.RegisterScenarios(scenario)
.WithReportingInterval(TimeSpan.FromSeconds(5))
.WithReportFolder("load-test-results")
.WithReportFormats(ReportFormat.Html, ReportFormat.Csv, ReportFormat.Md)
.Run();
// Check stats after run and assert thresholds
var stats = NBomberRunner
.RegisterScenarios(scenario)
.Run();
var browseStats = stats.ScenarioStats.First(s => s.ScenarioName == "browse");
// Assert p99 latency threshold
browseStats.StepStats[0].Ok.Latency.Percentile99
.Should().BeLessThan(500); // fail if p99 > 500ms
// Assert error rate
var errorRate = (double)browseStats.StepStats[0].Fail.Request.Count
/ browseStats.StepStats[0].Ok.Request.Count;
errorRate.Should().BeLessThan(0.01); // fail if > 1% errorsIn CI, run this as a step after deployment to a staging environment:
# GitHub Actions example
- name: Run load tests
run: dotnet run --project LoadTests/LoadTests.csproj
env:
API_BASE_URL: https://staging.api.example.comIf the load test process exits with code 1 (triggered by a failed assertion), the CI step fails and the deployment is blocked.
NBomber vs k6 vs Locust
All three are capable load testing tools. The choice depends on your team and context:
NBomber — best when your team is .NET-first. Tests are written in C# with full access to your domain models, shared configuration, and existing test infrastructure. No context switching. The tradeoff is that non-.NET engineers cannot easily write or modify tests.
k6 — JavaScript DSL, excellent for teams with JavaScript experience, strong CI/CD integration, huge ecosystem of plugins, and Grafana integration for dashboards. Better choice for a polyglot team or when you need to share load test scripts with frontend engineers.
Locust — Python, very easy to get started, HTTP-native, excellent for teams already using Python. The weakest on Windows and .NET integration.
If you already have an xUnit/NUnit test suite and your QA team writes C#, NBomber is the natural fit. It runs in the same toolchain, shares the same models, and produces reports in a format your CI already handles.
Conclusion
NBomber fills a gap in the .NET testing ecosystem: load tests that live in the same project, use the same language, and integrate with the same CI pipeline as your unit and integration tests. By expressing load scenarios as C# code, you get type safety, refactoring support, and the ability to reuse existing fixtures and helpers. Start with a single scenario for your critical path, establish a p99 baseline, and add threshold assertions to prevent regressions. Once you have that in CI, every deployment is automatically verified against your performance contract.