Workload Modeling and Test Data: Realistic Traffic Mix, Think Time, Cache State and Faking Dependencies
A realistic load test measures the traffic mix from the access log, separates session percentages from request percentages, uses random think time and runs with production-sized and production-shaped data, a deliberately chosen cache state and stubbed dependencies. With real runs on Bookstore we show that a test always asking for the same book sent only 41 queries to the database in a minute, and that a zero-delay payment stub made orders look twenty times faster.
Version note: The code in this post was built and run with .NET 10; the load tests were run with k6 2.4.0 against Bookstore.Api and SQL Server 2025 on Docker Compose, with a payment provider stub built on WireMock.Net 2.31.
Guess First
In Bookstore, the online bookstore used as the shared example across this site's series, the book details endpoint got a one-minute output cache. The team tests the endpoint at 600 requests per second in three different ways: the same book (Dune) on every request, an equal chance for each of the 40,000 books, or a realistic distribution that piles up on a few popular books. How do you think the number of queries reaching SQL Server differs between the three tests? Which one actually tests the database?
Second question: if 10 percent of the sessions in the scenario are checkouts, what share do the checkout endpoints get in the API's access log? The tests in post 01 went to a single endpoint and a 20-book catalog. In this post we bring the workload model closer to reality: traffic mix, session flow and think time, data size and distribution, cache state and external dependencies.
Deriving the Traffic Mix from the Access Log
The first input of a workload model is the distribution of production traffic: which endpoint gets what share of requests, in what order users move through the site. You get this by measuring, not guessing. The source can be the access log of the reverse proxy or the application, or per-endpoint request metrics. I added ASP.NET Core's W3C-format access log to Bookstore.Api. It is switched on and off through configuration, because writing every request to disk is not free:
// Optional W3C access log (one line per request); used to derive the traffic mix for load tests.
bool accessLogEnabled = builder.Configuration.GetValue<bool>("AccessLog:Enabled");
if (accessLogEnabled)
{
builder.Services.AddW3CLogging(options =>
{
options.LoggingFields = W3CLoggingFields.Date | W3CLoggingFields.Time | W3CLoggingFields.Method
| W3CLoggingFields.UriStem | W3CLoggingFields.UriQuery | W3CLoggingFields.ProtocolStatus
| W3CLoggingFields.TimeTaken;
options.LogDirectory = builder.Configuration["AccessLog:Directory"] ?? "logs";
options.FlushInterval = TimeSpan.FromSeconds(2);
});
}
#Fields: date time cs-method cs-uri-stem cs-uri-query sc-status time-taken
2026-11-14 12:21:56 POST /api/carts - 201 70.0251
2026-11-14 12:21:56 PUT /api/carts/01a1da01-d7ce-7741-b549-909d2599fbdb/items/9780441172719 - 200 155.3652
2026-11-14 12:21:56 POST /api/orders - 201 262.6972
To get ratios out of the raw log, paths have to be reduced to endpoint templates: /api/books/9780441172719 and /api/books/9790000000018 are the same endpoint. I wrote a single-file tool for this with .NET 10's file-based apps; there is no project file, it runs with dotnet run:
#:property PublishAot=false
// Reads W3C access logs and prints the request mix per endpoint and the share of the most viewed books.
// Usage: dotnet run load/tools/traffic-mix.cs -- <log folder>
using System.Globalization;
using System.Text.RegularExpressions;
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
string folder = args.Length > 0 ? args[0] : "logs";
Dictionary<string, int> endpoints = [];
Dictionary<string, int> bookViews = [];
int total = 0;
string[] fields = [];
foreach (string file in Directory.GetFiles(folder, "*.txt").Order())
{
foreach (string line in File.ReadLines(file))
{
if (line.StartsWith("#Fields:"))
{
fields = line["#Fields:".Length..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
continue;
}
if (line.StartsWith('#') || line.Length == 0)
{
continue;
}
string[] values = line.Split(' ');
string method = values[Array.IndexOf(fields, "cs-method")];
string path = values[Array.IndexOf(fields, "cs-uri-stem")];
string query = values[Array.IndexOf(fields, "cs-uri-query")];
string endpoint = method + " " + Normalize(path);
if (endpoint == "GET /api/books" && query.Contains("search="))
{
endpoint = "GET /api/books?search=";
}
endpoints[endpoint] = endpoints.GetValueOrDefault(endpoint) + 1;
if (endpoint == "GET /api/books/{isbn}")
{
bookViews[path] = bookViews.GetValueOrDefault(path) + 1;
}
total++;
}
}
// ... prints the percentages and the share of the 10/100/1000 most viewed books
// /api/books/9780441172719 → /api/books/{isbn}, /api/carts/0196.../items/... → /api/carts/{id}/items/{isbn}
static string Normalize(string path)
{
path = Regex.Replace(path, "[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}", "{id}");
path = Regex.Replace(path, @"/\d{13}(?=/|$)", "/{isbn}");
path = Regex.Replace(path, @"/orders/\d+$", "/orders/{id}");
return path.TrimEnd('/');
}
In production you run this tool over a week of logs and take the mix from there. Bookstore has no production, so I validated the method in the other direction: I ran the k6 scenario we build below for three minutes and fed the log it produced to the tool. The scenario defines 70 percent of sessions as browsing, 20 percent as search and 10 percent as checkout:
dotnet run load/tools/traffic-mix.cs -- ./accesslog
10047 requests
58.4% GET /api/books/{isbn}
23.9% GET /api/books
7.1% GET /api/books?search=
3.5% POST /api/carts
3.5% PUT /api/carts/{id}/items/{isbn}
3.5% POST /api/orders
4498 distinct books viewed
top 10 books: 6.4% of book views
top 100 books: 14.9% of book views
top 1000 books: 40.4% of book views
Here is the answer to the second guess question: 10 percent checkout sessions are 10.5 percent of requests, but each checkout endpoint is only 3.5 percent. The 20 percent search sessions are just 7.1 percent of requests. Session percentages and request percentages are different things, because every flow sends a different number of requests: browsing 3, search 2, checkout 4. The numbers from the log are request percentages, the numbers you write in the scenario are session percentages. If you build a scenario with "3.5 percent orders, as in the log" without converting one into the other, you test checkout three times too little.
Session Flow and Think Time
Real users do not call endpoints at random; they follow a flow: a category list, a book, another book. The order matters too, because an order cannot be placed before a cart exists, and the effect of each step on the cache and the database depends on the step before it. Bookstore's workload model runs in the open model: a given number of sessions arrive per second, each session picks a flow and thinks between steps:
export const options = {
scenarios: {
sessions: {
executor: 'constant-arrival-rate',
rate: Number(__ENV.RATE || 20),
timeUnit: '1s',
duration: __ENV.DURATION || '5m',
// Little's Law: 20 sessions/s x ~8.5 s per session = ~170 concurrent sessions.
preAllocatedVUs: 200,
maxVUs: 1000,
},
},
};
function think(min, max) {
sleep(THINK * (min + Math.random() * (max - min)));
}
function browse() {
const category = categories[Math.floor(Math.random() * categories.length)];
const page = 1 + Math.floor(Math.random() * 5);
const list = http.get(`${BASE_URL}/api/books?category=${category}&page=${page}`, { tags: { name: 'GET /api/books' } });
check(list, { 'list 200': (r) => r.status === 200 });
think(2, 5);
viewBook(pickIsbn());
think(3, 8);
viewBook(pickIsbn());
}
export default function () {
const r = Math.random();
if (r < 0.7) browse();
else if (r < 0.9) search();
else checkout();
}
The think-time ranges come from the log as well: the distribution of the time between two requests in the same session. Skipping think time is a common mistake: you generate many more requests with the same number of sessions, but you never see the number of sessions, connections and carts the system holds at the same time under real load. Little's Law helps here too. The average session duration measured by k6 is 8.43 seconds; with 20 sessions per second about 169 sessions should be open at once. The highest measured value is 179 VUs.
In the first attempt preAllocatedVUs was 50 and k6 could not start 133 sessions (dropped_iterations). The system was not slow; k6 could not open new VUs fast enough during the load. Reserving 200 VUs up front, based on the concurrency computed with Little's Law, removes that loss. There is a trap on the other side too: do not use a fixed think time. I first wrote the closed-model test in post 01 so that everyone thought for exactly one second. Because 500 VUs started together and woke up together, requests piled up at the start of every second and p95 came out at 59 ms. The same test with a random wait between 0.5 and 1.5 seconds gave 2.97 ms. What I had measured was not the system but the users that the test script itself had synchronized.
Test Data: Size and Distribution
A 20-book catalog does not show the same query plan, index usage and memory behavior as a 40,000-book catalog. Bookstore's estimated catalog size is 40,000 books; the T-SQL script that brings the test database to that size works set-based and produces valid ISBN-13s. Each ISBN is the '979' prefix, a 9-digit sequence number and a check digit:
WITH numbers AS (
SELECT TOP (40000 - (SELECT COUNT(*) FROM catalog.Books))
ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
FROM sys.all_objects a CROSS JOIN sys.all_objects b
),
isbn12 AS (
SELECT n, '979' + RIGHT('000000000' + CAST(n AS varchar(9)), 9) AS digits FROM numbers
)
INSERT catalog.Books (Isbn, Title, Description, Price, PublishedYear, AuthorId, CategoryId)
SELECT
i.digits + CAST((10 - (
CAST(SUBSTRING(i.digits, 1, 1) AS int) + 3 * CAST(SUBSTRING(i.digits, 2, 1) AS int)
+ CAST(SUBSTRING(i.digits, 3, 1) AS int) + 3 * CAST(SUBSTRING(i.digits, 4, 1) AS int)
+ CAST(SUBSTRING(i.digits, 5, 1) AS int) + 3 * CAST(SUBSTRING(i.digits, 6, 1) AS int)
+ CAST(SUBSTRING(i.digits, 7, 1) AS int) + 3 * CAST(SUBSTRING(i.digits, 8, 1) AS int)
+ CAST(SUBSTRING(i.digits, 9, 1) AS int) + 3 * CAST(SUBSTRING(i.digits, 10, 1) AS int)
+ CAST(SUBSTRING(i.digits, 11, 1) AS int) + 3 * CAST(SUBSTRING(i.digits, 12, 1) AS int)) % 10) % 10 AS varchar(1)),
a.v + N' ' + o.v + CASE WHEN i.n > 1200 THEN N' ' + CAST(i.n / 1200 + 1 AS nvarchar(10)) ELSE N'' END,
N'Generated book for load tests.',
CAST(99 + (i.n * 37) % 900 AS decimal(10, 2)) + 0.90,
1950 + i.n % 76,
@firstAuthorId + i.n % 4000,
1 + i.n % 4
FROM isbn12 i
JOIN @adj a ON a.n = i.n % 30
JOIN @noun o ON o.n = (i.n / 30) % 40;
# PowerShell 7
docker compose cp load/data/seed-catalog.sql sql:/tmp/seed-catalog.sql
docker compose exec -T sql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P $env:SA_PASSWORD -d BookstoreDb -b -i /tmp/seed-catalog.sql
docker compose exec -T sql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P $env:SA_PASSWORD -d BookstoreDb -h -1 -W `
-Q "SET NOCOUNT ON; SELECT Isbn FROM catalog.Books ORDER BY Id" > load/data/isbns.txt
The last command writes the ISBNs to a file. On the k6 side this file is read once with SharedArray and all VUs share the same copy; a copy per VU would mean wasted memory at 200 VUs. The SQL Server password is visible on the command line; only do this with a local test container.
Distribution matters as much as size. Not every book in a bookstore gets the same attention: a few hundred popular books take most of the traffic, while tens of thousands of others are opened rarely. I imitated this in the scenario with a simple curve. Cubing a random number favors small indexes, that is, the books at the start of the list. The measurement from the log shows that the 1,000 most viewed books, 2.5 percent of the catalog, got 40 percent of the views. In production do not guess this curve, measure it from the log; the tool above prints the "top N" lines for exactly that.
One more thing: test data can run out. Each of the 20 classics in the seed data had 25 copies in stock, and Don Quixote had none. Because popular books are at the start of the list, checkouts piled up on them, and in a three-minute run one order came back with 409 "Not enough stock". In a one-hour soak test Dune runs out in the first minutes and the error rate climbs. That is a data problem, not a system problem. That is why the script tops up all stock in the load-test database to one million. The same applies to test users, coupon codes and tokens: VUs that share a single-use resource either break each other or produce false errors.
Cache State: Warm, Cold and Fake Hits
I added a one-minute output cache to book details. Book information rarely changes, and the stock figure on the page may be up to a minute old. The details of that trade-off and of measuring the hit ratio are in the Caching series post "Measuring and Testing Caches: Hit Ratio, Metrics, Memory Usage and Consistency Tests".
// Book details change rarely: one minute of output cache (stock may be up to a minute old on this page).
catalog.MapGet("/books/{isbn}", GetBook).CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(1)));
Then I ran the three tests from the guess question: 600 requests per second for 60 seconds, the only difference being how the book is picked. I measured the load the database saw with the difference of SQL Server's Batch Requests/sec counter before and after each test. Every request that misses the cache sends two queries: the book and the stock.
k6 run -e POPULARITY=single -e RATE=600 book-details.js
k6 run -e POPULARITY=uniform -e RATE=600 book-details.js
k6 run -e POPULARITY=zipf -e RATE=600 book-details.js
| Book selection | Requests | SQL batches | Approx. hit ratio | p50 | p95 |
|---|---|---|---|---|---|
| Always the same book | 35,972 | 41 | 100 percent | 0.40 ms | 0.78 ms |
| Uniform (40,000 books) | 36,001 | 40,165 | 44 percent | 1.01 ms | 1.82 ms |
| Piled on popular books | 36,001 | 36,184 | 50 percent | 0.87 ms | 2.06 ms |
The test that always asks for the same book sent 41 queries to the database in a minute; the other two sent 36 to 40 thousand. The first test tested the cache, not the API. Its "p95 0.8 ms" result says nothing about the load the database will see on a campaign evening. The timings are close to each other on this machine because 40,000 rows fit in SQL Server's memory and the queries read a single row through an index. When the database is busy with other traffic, or the data does not fit in memory, the difference between 41 and 36,000 queries goes straight into the latency. The uniform distribution is not realistic either: it never lets the popular books stay in the cache, so it shows the hit ratio lower than it is.
The starting state of the cache changes the result as well. I restarted the API container and ran the same test for 30 seconds, then right after that for another 30 seconds:
== cold
http_req_duration..............: avg=2.43ms min=146µs med=1.08ms max=798.96ms p(90)=2.17ms p(95)=2.89ms
== warm
http_req_duration..............: avg=829.95µs min=152µs med=339µs max=59.83ms p(90)=1.54ms p(95)=1.93ms
In the cold run the median is three times higher and the slowest request takes 800 ms. JIT compilation, a filling connection pool and an empty cache all play a part in those first seconds. Decide on purpose which one you measure. If you care about the first minute after a deploy, start cold and report that minute separately. If you measure the steady state, add a warm-up phase and leave it out of the results. In post 03 we do that with a separate scenario or a ramp-up.
Faking Dependencies: A Payment Stub with WireMock.Net
Bookstore's checkout sends a charge request to the payment provider after saving the order. In a load test it is wrong to send that request to the real provider, or even to its sandbox. You would be sending load to the provider's system without permission, most sandboxes have request limits, and the timing you measure would include a system you do not control. The solution is to replace the dependency with a stub. The stub has to follow the real provider's API contract, and its latency has to follow the provider's measured production latency. First the API side: the charge goes through an HttpClient and the address comes from configuration. Without an address no charge is made and the order stays "Placed"; that is why the existing integration tests did not change. I verified with a fake IPaymentGateway in two new tests that the payment outcome is reflected in the order status.
// Calls the payment provider (in load tests: a WireMock.Net stub, never the real provider).
internal sealed class HttpPaymentGateway(HttpClient http, ILogger<HttpPaymentGateway> logger) : IPaymentGateway
{
public async Task<ChargeOutcome> ChargeAsync(int orderId, decimal amount, string currency, CancellationToken ct)
{
try
{
using HttpResponseMessage response = await http.PostAsJsonAsync(
"v1/charges", new ChargeRequest($"order-{orderId}", amount, currency), ct);
return response.IsSuccessStatusCode ? ChargeOutcome.Succeeded : ChargeOutcome.Failed;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
{
logger.LogWarning(ex, "Payment for order {OrderId} failed", orderId);
return ChargeOutcome.Failed;
}
}
}
public static class PaymentsModule
{
public static IServiceCollection AddPaymentsModule(this IServiceCollection services, IConfiguration configuration)
{
if (configuration["Payments:BaseUrl"] is { Length: > 0 } baseUrl)
{
services.AddHttpClient<IPaymentGateway, HttpPaymentGateway>(client =>
{
client.BaseAddress = new Uri(baseUrl);
client.Timeout = TimeSpan.FromSeconds(5);
});
}
else
{
services.AddSingleton<IPaymentGateway, NoPaymentGateway>();
}
return services;
}
}
await transaction.CommitAsync(ct);
// The charge runs after the commit: an external call never holds stock rows locked.
ChargeOutcome charge = await payments.ChargeAsync(order.Id, order.Total, order.Currency, ct);
if (charge != ChargeOutcome.Skipped)
{
order.Status = charge == ChargeOutcome.Succeeded ? OrderStatus.Paid : OrderStatus.PaymentPending;
await db.SaveChangesAsync(ct);
}
The charge request goes out after the transaction is committed. Keeping stock rows locked for as long as an external call takes would tie every checkout under load to the speed of that call. The stub is a small console app written with WireMock.Net:
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;
// Stand-in for the payment provider during load tests: never send load to a real provider or its sandbox.
// Usage: dotnet run -c Release -- [port] [minDelayMs] [maxDelayMs]
int port = args.Length > 0 ? int.Parse(args[0]) : 5099;
int minDelay = args.Length > 1 ? int.Parse(args[1]) : 0;
int maxDelay = args.Length > 2 ? int.Parse(args[2]) : minDelay;
using WireMockServer server = WireMockServer.Start(new WireMockServerSettings { Port = port });
IResponseBuilder response = Response.Create()
.WithStatusCode(201)
.WithHeader("Content-Type", "application/json")
.WithBody("""{ "id": "ch_test", "status": "succeeded" }""");
if (maxDelay > 0)
{
// A real provider is never equally fast: every response waits a random time in [minDelay, maxDelay].
response = response.WithRandomDelay(minDelay, maxDelay);
}
server.Given(Request.Create().WithPath("/v1/charges").UsingPost()).RespondWith(response);
Console.WriteLine($"Payment stub on http://localhost:{port}/v1/charges, delay {minDelay}-{maxDelay} ms. Ctrl+C to stop.");
using CancellationTokenSource stop = new();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Cancel(); };
try
{
await Task.Delay(Timeout.Infinite, stop.Token);
}
catch (OperationCanceledException)
{
}
Console.WriteLine($"Charges received: {server.LogEntries.Count()}");
# terminal 1: payment stub, 100-300 ms delay
dotnet run -c Release --project tools/Bookstore.PaymentStub -- 5099 100 300
# terminal 2 (PowerShell): recreate the API so that it uses the stub
$env:PAYMENTS_BASE_URL = "http://host.docker.internal:5099"
docker compose up -d api
k6 run -e RATE=20 -e DURATION=2m load/k6/bookstore-mix.js
I ran the same mix for two minutes, first with a zero-delay stub, then with a 100-300 ms stub. I separated the order endpoint's timings from k6's CSV output:
| Stub delay | POST /api/orders p50 | POST /api/orders p95 | p95 of all requests |
|---|---|---|---|
| 0 ms | 9.6 ms | 41.9 ms | 99 ms |
| 100-300 ms | 219 ms | 300.7 ms | 154 ms |
The zero-delay stub makes the order endpoint's median look more than twenty times faster. An SLO set from that number is breached by the first real payment delay. The effect of the delay does not end with timing either. While it waits for the provider, every order request holds a request on the server, an HttpClient connection and memory. With Little's Law: at 200 orders per second, a 0.22-second wait means 44 open payment calls at the same time. That is why I gave the stub a range rather than a fixed delay. The best approach is to take the provider's real p50 and p95 from your own metrics and set the range accordingly. The same logic applies when the provider slows down or returns errors: add a 5-second delay or 5 percent errors to the stub and you see the timeout and retry behavior under load. Those behaviors themselves are covered in the System Design series post "Reliability Patterns: Timeouts, Retries, Backoff with Jitter, Circuit Breakers and Load Shedding".
For a CDN the rule works the other way round: run the load test directly against the origin, not in front of the CDN. A test in front of the CDN measures the CDN provider's system, and you would be sending it load without permission. Your capacity problem is the moment the requests the CDN misses reach the origin. To account for the CDN's hit ratio in the origin test, leave part of the cacheable requests out of the scenario, that is, model only the traffic that passes through the CDN and reaches the origin.
Production Notes
Do not let the load generator become the bottleneck. k6 can generate a lot of load as a single Go process, but if its CPU saturates it sends requests late, and that delay shows up in the results as if it were the system's latency. In the tests in this post k6 and the API were on the same machine; that is another reason the absolute numbers are machine-specific. In a real test environment put the load generator on a separate machine and watch its CPU throughout the test.
Testing on the same network brings network latency down to zero. Bookstore's users reach the API over the internet and over TLS; a request sent from the same machine measures neither. That can be a deliberate choice, because you are measuring the server's capacity, but write it in the report and do not mix up user-side timings with server timings. How to separate latency in the network layer is in the How Networking and HTTP Work series post "Where Does a Slow Request Come From".
If the system has a rate limiter, check whether it fires during the load test. Since all virtual users come from a single IP, an IP-based limit that would never kick in with real traffic kicks in during the first second of the test. The result is either plenty of 429 (Too Many Requests) responses or a misleadingly good p95, because the limit response returns very fast. Check status codes and count 429s separately; if you disable the limit in the test environment, write that in the report too.
Common Pitfalls
- Writing the request percentages from the log into the scenario as session percentages (or the other way round).
- Skipping think time or making it fixed: one distorts concurrency, the other the request distribution.
- Testing with the same ID every time: the cache and the database buffer make the result look falsely good.
- Measuring with a 20-row database and carrying the result over to a 40,000-row production.
- Mistaking an error caused by exhausted test data (stock, coupons, single-use tokens) for a system error.
- Averaging a cold start and the steady state into the same result.
- Sending load to a payment provider's sandbox or to a CDN; the origin has to be tested with stubs.
- Reporting a "very fast" result without noticing the rate limiter's 429s.
A Small Experiment
★☆☆ Run bookstore-mix.js with -e THINK=0. At the same session rate, how did the request count, the highest VU count and p95 change? Explain with Little's Law.
★★☆ Turn on the access log, run bookstore-mix.js for two minutes and run traffic-mix.cs on the log. Then recompute the percentages in the scenario so that the log shows 15 percent checkout requests, and verify it.
★★★ Add a second mapping to the payment stub that returns 500 for 5 percent of requests (WithProbability in WireMock.Net, or a counter). Query how many orders stay in PaymentPending, and discuss whether that should count as an SLO violation.
The workload model is ready. In the next post we turn it into a full load test in k6: a TypeScript script, a separate scenario per flow with arrival-rate executors, per-endpoint thresholds, setup() and the expect() assertions that came with 2.0, a machine-readable JSON summary and reading the output line by line.