MockServer Performance Benchmarks: Throughput & Latency
A single MockServer instance holds a sub-millisecond median up to a healthy ceiling of 136,000 requests/sec on six CPU cores, with 99th-percentile latency within 10 ms outside the load generators' own garbage-collection pauses (17.788 ms with every second counted), and request matching stays flat — up to 177× faster than a linear scan — as you scale to thousands of expectations.
| Page | Question it answers |
|---|---|
| Scalability & Latency | How much traffic can one instance serve as a mock? |
| Load Injection | How do I drive load from MockServer? |
| Load Injection Performance | How much load can MockServer inject, and how does it scale? |
MockServer is built for high throughput and low, predictable latency from a single instance. The figures below come from an automated performance suite that runs on a dedicated, core-pinned CI machine; every chart is regenerated from the measured data on each run, so what you see is reproducible. Jump to how MockServer is performance tested for the methodology, or run the suite yourself from mockserver-performance-test/.
- Throughput & latency: on six CPU cores a single instance holds a sub-millisecond median and a sub-millisecond client-observed p95 all the way to 120,000 requests/sec, and its median stays sub-millisecond up to a healthy ceiling of about 136,000 requests/sec (median 0.104 ms, p95 3.347 ms). Its p99 there is within 10 ms only with the load generators' own garbage-collection pauses left out: 9.572 ms over the seconds in which none of them was collecting, and 17.788 ms with every second counted (why). In the twelve like-for-like runs behind this figure the ceiling read 136,000 req/s in six, 144,000 req/s in three and 128,000 req/s in three; the figure published here is the most common of those rates. Provision against the lowest of those rates (128,000 req/s). Offered 160,000 req/s it still served 158,854, but past the ceiling it no longer met every healthy criterion, so that is a point on the overload curve, not an operating target (see the throughput vs latency table).
- Scales with expectations: request matching cost stays nearly flat as the number of registered expectations grows — up to 177× faster than a naive linear scan at 5,000 expectations — thanks to MockServer's candidate-matching index (see the matcher scaling chart).
- Protocol coverage: HTTP/1.1, HTTPS and HTTP/2 are all served with low overhead, measured side by side on every run (see latency by action).
Performance at a glance
The charts in this section plot the daily run on a smaller machine (c5.12xlarge), driven by a single load generator. That load generator runs out at about 60,000 requests/sec, well before a six-core MockServer does, so the charts show the shape of the curve at lower rates; the headline figures above come from the multi-generator run in the throughput vs latency table.
As offered load increases, achieved throughput tracks it almost exactly while the median stays flat — right up to the highest rate this run's single load generator can deliver. That flat region is the headroom you can rely on; beyond it, latency rises predictably rather than collapsing.
Mock servers are often run with hundreds or thousands of expectations. MockServer keeps matching cheap at scale by narrowing each request to a small set of candidate expectations instead of testing every one. The difference grows with the number of expectations:
At the highest rate that single load generator delivered cleanly — around 60,000 requests/sec — the median stays sub-millisecond at 0.179 ms and the client-observed p95 is 21.893 ms. Most of that tail is the single load generator's own: driven by 4 load generators, the p95 stays under a millisecond to 120,000 requests/sec (see the table). The p95 and p99 figures are client-observed end-to-end — see the note under that table for what they mean.
The same actions measured over HTTP/1.1 and over HTTPS with HTTP/2 show that TLS and HTTP/2 add only a fraction of a millisecond — even a forwarded request or a large templated response stays within single-digit milliseconds:
What makes MockServer scale
The throughput and latency above come from a few deliberate design choices:
- Netty — an asynchronous, event-driven network framework. A small pool of event-loop threads handles many thousands of concurrent connections without a thread per request, which is what keeps latency flat as load rises. Where the OS supports it, MockServer uses Netty's native epoll transport for lower per-request overhead than the portable NIO transport.
- Candidate-matching index — instead of testing a request against every expectation in turn (an O(n) scan), MockServer indexes expectations so each request is compared only against a small candidate set. This is why matching cost stays nearly flat as expectations grow (chart above). It falls back to a scan only for expectations that cannot be indexed (for example, fully regex-based paths).
- LMAX Disruptor — a high-performance inter-thread messaging library used to record events (request log, recorded requests) and logging off the request path, so capturing state does not block responses.
- Forward-proxy connection pooling — when MockServer forwards or proxies requests upstream, connections are pooled and reused, avoiding per-request connection setup and ephemeral-port exhaustion under sustained forward load.
- Fail-fast matchers — request matching short-circuits on the first non-match, so non-matching expectations are rejected as cheaply as possible.
- Bounded in-memory log — the request/event log is a fixed-size ring buffer (maxLogEntries), so memory stays bounded under sustained load. For long-running, high-throughput instances this is the most important thing to tune — see memory & troubleshooting below.
- ScheduledThreadPoolExecutor — response delays are scheduled rather than blocking a thread, so simulated latency does not consume the event-loop threads.
Most of these are configurable — thread-pool sizes, native transport, and matcher behaviour are covered under scalability configuration below.
How MockServer is performance tested
The numbers on this page are produced by a performance suite (mockserver-performance-test/) built around k6 for load generation and the JMH microbenchmark harness for component-level measurement. A few principles make the results trustworthy:
- Open-model load (no coordinated omission). Load is generated at a fixed arrival rate rather than by a fixed number of looping clients. This avoids coordinated omission — the measurement artefact where slow responses hide further slow responses — so the reported latency is what a real caller would experience under that offered load.
- A ladder of test types, each answering a different question:
- load — steady throughput at a target rate; the CI pass/fail gate.
- sweep — steps the arrival rate up a ladder to find the throughput/latency knee (the first chart).
- regression / growth — fixed-rate latency per behaviour (match, forward, template, large body) over HTTP and HTTPS+HTTP/2, plus a check that latency and memory do not creep upward over a run.
- microbenchmarks — JMH measurement of matching time and allocation per request, including the candidate-index scaling sweep.
- stress and soak — scenarios included in the suite for you to run on demand: stress pushes past the knee to find the breaking point, soak holds sustained load to surface memory growth and leaks. The figures on this page come from the load, sweep, regression/growth and microbenchmark runs above.
- Reproducible and core-pinned. Measurement runs on a dedicated CI machine with the server, upstream and load generator pinned to separate CPU cores so they do not steal cycles from each other — the single biggest factor in number quality. Every chart is regenerated from the pipeline's JSON output.
- Enough load generators. One k6 process cannot drive a six-core MockServer to its limit — on its own it runs out at about 60,000 requests/sec. The headline run therefore drives one MockServer from 4 k6 processes on the machine's other CPU socket and merges their measurements, so the ceiling is no longer set by one load generator running out.
- Automated regression detection. Each run is stored and compared against a rolling baseline using a robust median + MAD test, so a real slowdown is flagged without false alarms from run-to-run noise.
Measurement provenance for the headline figures on this page. Every number below is dated and tied to the run that produced it — a figure without that context is how these claims went stale before:
| measured | 2026-10-12T06:40:10Z · build #667 · commit ba2211b380 |
| MockServer version | 8.0.1-SNAPSHOT |
| hardware | AWS c6i.32xlarge, server pinned to 6 physical cores (CPUs 0-5); 4 k6 load generators, each pinned to 16 CPUs on the other CPU socket |
| JVM / GC / heap | JDK 25.0.4.1+1-LTS, ZGC Minor Cycles,ZGC Minor Pauses,ZGC Major Cycles,ZGC Major Pauses, 922 MiB max heap |
| log level | ERROR, system-out disabled — reduced below the shipped INFO default for measurement, so these are not default-configuration figures |
| healthy ceiling rule | achieved within 5 % of offered, no errors, median flat, and p99 within 10 ms over the seconds with no load-generator garbage collection (at least 3 such seconds per rate) |
Measured on a dedicated CI host with MockServer pinned to its own physical cores and the load generators on the other CPU socket. Logging is reduced below the shipped INFO default for measurement (see log_level). The charts and the latency-by-action table come from the daily single-load-generator run: 2026-09-27T18:33:52Z · build #464 · c5.12xlarge · 1230 MiB max heap.
Real-world throughput and latency depend on your hardware, core count, payload size, expectation complexity and JVM/configuration tuning — more CPU cores raise peak throughput, and reducing what the request log retains (logLevel or maxLogEntries) frees heap and cuts allocation on the matching path, while disableSystemOut and disableLogging only silence console output and change neither — so treat these as a solid, dated reference and benchmark your own workload.
Detailed results
Throughput vs latency
Offered request rate against achieved throughput and response-latency percentiles for a single instance matching a simple expectation. The healthy ceiling is the highest offered rate where the instance kept up (achieved within 5 % of offered, with no errors), its median stayed flat and its p99 with load-generator garbage collection left out (the second p99 column, explained below) stayed within 10 ms, at that rate and at every rate tested below it; rows past it are shaded — they are the overload curve, published for honesty rather than as an operating target.
| offered req/s | achieved req/s | p50 | p95 | p99 | p99, generator GC left out | seconds counted | errors | state |
| 500 | 500 | 0.471 | 0.629 | 0.686 | 0.687 | 11 of 11 | 0% | healthy |
| 1,000 | 1,000 | 0.452 | 0.576 | 0.632 | 0.632 | 11 of 11 | 0% | healthy |
| 2,000 | 2,000 | 0.346 | 0.441 | 0.534 | 0.534 | 11 of 11 | 0% | healthy |
| 4,000 | 4,000 | 0.25 | 0.47 | 0.629 | 0.628 | 11 of 11 | 0% | healthy |
| 8,000 | 7,990 | 0.217 | 0.51 | 0.696 | 0.699 | 9 of 11 | 0% | healthy |
| 16,000 | 16,000 | 0.16 | 0.467 | 0.677 | 0.678 | 10 of 11 | 0% | healthy |
| 24,000 | 23,941 | 0.117 | 0.415 | 0.657 | 0.652 | 11 of 11 | 0% | healthy |
| 32,000 | 32,000 | 0.084 | 0.272 | 0.41 | 0.409 | 11 of 11 | 0% | healthy |
| 36,000 | 35,923 | 0.084 | 0.254 | 0.388 | 0.389 | 11 of 11 | 0% | healthy |
| 40,000 | 40,000 | 0.084 | 0.244 | 0.393 | 0.397 | 7 of 11 | 0% | healthy |
| 44,000 | 44,000 | 0.082 | 0.235 | 0.386 | 0.387 | 11 of 11 | 0% | healthy |
| 48,000 | 48,000 | 0.082 | 0.21 | 0.372 | 0.373 | 11 of 11 | 0% | healthy |
| 56,000 | 56,000 | 0.083 | 0.198 | 0.349 | 0.352 | 8 of 11 | 0% | healthy |
| 64,000 | 64,000 | 0.087 | 0.162 | 0.291 | 0.294 | 11 of 11 | 0% | healthy |
| 72,000 | 72,000 | 0.094 | 0.167 | 0.311 | 0.296 | 8 of 11 | 0% | healthy |
| 80,000 | 80,000 | 0.09 | 0.157 | 0.306 | 0.292 | 11 of 11 | 0% | healthy |
| 96,000 | 96,001 | 0.092 | 0.238 | 0.878 | 0.644 | 8 of 11 | 0% | healthy |
| 104,000 | 104,001 | 0.093 | 0.347 | 11.603 | 0.805 | 8 of 11 | 0% | healthy |
| 112,000 | 112,001 | 0.094 | 0.477 | 5.231 | 2.014 | 9 of 11 | 0% | healthy |
| 120,000 | 120,001 | 0.096 | 0.633 | 3.587 | 3.232 | 11 of 11 | 0% | healthy |
| 128,000 | 127,924 | 0.098 | 1.117 | 5.913 | 5.99 | 11 of 11 | 0% | healthy |
| 136,000 | 136,327 | 0.104 | 3.347 | 17.788 | 9.572 | 10 of 11 | 0% | healthy |
| 144,000 | 143,452 | 0.109 | 6.942 | 40.253 | 13.651 | 8 of 11 | 0% | past the ceiling |
| 152,000 | 151,340 | 0.119 | 11.596 | 50.782 | 15.083 | 7 of 11 | 0% | past the ceiling |
| 160,000 | 158,854 | 0.146 | 17.094 | 55.295 | 20.057 | 6 of 11 | 0% | past the ceiling |
Each rate is held for a fixed step. The first 3 seconds after each step up are left out of the latency percentiles, because they capture the load generator starting up at the new rate rather than the server; throughput and errors still count the whole step.
Latencies are in milliseconds. Achieved throughput tracks the offered rate up to the healthy ceiling of 136,000 req/s (median 0.104 ms, p95 3.347 ms, p99 9.572 ms with load-generator garbage collection left out and 17.788 ms over the whole step, on 6 cores). The next rate tested, 144,000 req/s, is the first past the ceiling: its p99 with load-generator garbage collection left out was 13.651 ms. The most the instance served at any rate tested was about 158,854 req/s, at 160,000 offered — a point on the overload curve, not a rate to size a deployment for. Rates near the ceiling are tested in steps of 8,000 req/s, so the ceiling is accurate to about one step and can land one step either way from run to run. In the twelve like-for-like runs behind this figure the ceiling read 136,000 req/s in six, 144,000 req/s in three and 128,000 req/s in three; the figure published here is the most common of those rates. The three runs that read 128,000 req/s missed the 10 ms p99 bound at 136,000 req/s, one of them by less than 0.1 ms, so the ceiling sits close to that bound. Provision against the lowest of those rates (128,000 req/s). (More CPU cores raise it; this run pins the server to 6.)
Why there are two p99 columns. The load generator, k6, is a Go program and pauses briefly for its own garbage collection. A request that completes around such a pause is timed late by the client even when MockServer answered on time, and at high rates those pauses set most of the client-observed tail. The first p99 column counts every second of the step. The second leaves out each second in which any of the load generators was collecting, and the second after it; the “seconds counted” column says how many seconds remain. The 10 ms bound reads the second column. Judged on the first column, this run's ceiling would be 96,000 req/s. It is still a client-observed figure and a sample of the step, not a measurement of MockServer free of every outside effect: MockServer still served the burst that follows each pause. The second column still rises as the rate nears the ceiling, and this test does not show how much of that remaining tail is MockServer's.
What the p95 and p99 figures mean. These are client-observed, end-to-end latencies — every millisecond between the client dispatching the request and receiving the first byte of the response.
When those first seconds are counted, the slower tail at moderate load comes from the load rig, not MockServer: a transient at the start of each rate step, while the load generator starts up at the new rate. In a separate test on the single-load-generator rig, held at a steady 24,000 req/s, the client measured p99 0.343 ms and p99.9 1.43 ms, and none of the 7.25 million requests took the server more than 5 ms. That start-of-step transient is left out of the table above.
The recorded-request store is not application logging. The received-request and response entries it holds — the ones a verify reads — are stored whatever the log level, so lowering the level does not shrink them. It does drop the per-expectation match/did-not-match diagnostics, which are only produced at INFO or finer, so on a workload with many expectations a lower level still helps. It is the store behind request verification (verify), the retrieveRecordedRequests API, and the dashboard — it fills regardless of log level, and reaching for disableLogging=true when you see latency spikes will not help.
This measurement uses a small heap. The server ran with a maximum heap of 922 MiB, so its own garbage collection is exercised throughout. A deployment with a larger heap collects far less often.
Levers for heap use:
- Bound the recorded-request store — lower maxLogEntries or set a byte budget with maxEventLogSizeInBytes if you do not need the full verification history. This directly reduces the heap pinned by the store. The trade-off: fewer entries available for verify calls and the dashboard history.
- Give the JVM more heap — a larger -Xmx means the same number of entries consumes a smaller fraction of available heap, reducing collection frequency. The trade-off: higher memory commitment for the container or host.
These figures are per instance. The ~136,000 req/s healthy ceiling is what a single six-core instance served within the healthy criteria in this run — you scale beyond it by adding instances. In the twelve like-for-like runs behind this figure the ceiling read 136,000 req/s in six, 144,000 req/s in three and 128,000 req/s in three; the figure published here is the most common of those rates. Scaling beyond one instance: for more serving capacity (handling more inbound mock traffic) or high availability, run several instances as a cluster behind a load balancer — see Centralised Deployment. To drive more load injection (generating more outbound traffic with Load Scenarios), run injector instances in parallel — see Load Injection Performance, which measures how injected throughput scales near-linearly with the number of instances. To choose a container size for one instance, see throughput by hardware size.
Throughput by hardware size
To help you choose a container size, the same test was run against single MockServer containers of different sizes, each limited to that many CPU cores and that much memory and using the Docker image's default memory settings (no -Xmx). This is a separate run from the headline figures above, on a different machine (c5.12xlarge rather than c6i.32xlarge), so its 6-core row can differ from them; its rows marked ≥ are lower bounds and can sit well below the headline.
Healthy ceiling: the highest offered rate MockServer served at ≥95% with no errors and median latency within 3× of its median at the lowest rates tested, at that rate and at every rate tested below it. Measured on an AWS c5.12xlarge (24 physical cores); each size is a fresh container pinned to its own physical CPU cores; 4 k6 load generators run on 16 other cores. Rows marked ≥ may have been limited by the load generator, by the highest rate tested, or by something other than MockServer's CPU — MockServer handled at least that much.
Per core is the healthy ceiling divided by the cores; vs 1 core compares it with the 1-core row, so 1.00 would be perfectly linear scaling. Peak is the most MockServer served on any rate the load generator could deliver cleanly, which can be above the healthy ceiling once latency has started to rise. The latency columns are measured at the healthy ceiling. Rates are tested in steps, so each ceiling is accurate to about one step.
| CPU cores | memory | healthy ceiling req/s | per core | vs 1 core | peak req/s | median (p50) | p95 | notes |
| 1 | 512 MB | 14,300 | 14,300 | 1.00 | 25,179 | 0.174 ms | 2.543 ms | |
| 2 | 1 GB | 33,100 | 16,550 | 1.16 | 47,018 | 0.126 ms | 6.129 ms | |
| 2 | 2 GB | 38,300 | 19,150 | 1.34 | 50,947 | 0.155 ms | 11.008 ms | control: same cores, more memory |
| 3 | 1.5 GB | ≥ 49,700 | ≥ 16,566 | ≥ 1.16 | ≥ 49,751 | 0.134 ms | 7.531 ms | may have been limited by the load generator — lower bound |
| 4 | 2 GB | ≥ 57,200 | ≥ 14,300 | ≥ 1.00 | ≥ 57,085 | 0.127 ms | 14.799 ms | may have been limited by the load generator — lower bound |
| 6 | 2 GB | ≥ 59,700 | ≥ 9,950 | ≥ 0.70 | ≥ 59,356 | 0.112 ms | 17.614 ms | may have been limited by the load generator — lower bound |
What this run shows:
- On the sizes where MockServer itself was the limit (1 to 2 cores), throughput grew roughly in step with cores: about 14,300–16,550 requests/sec per core at the healthy ceiling.
- In this run, at 2 cores, the memory comparison (1 GB: 33,100 req/s, 2 GB: 38,300 req/s) is inconclusive: the two ceilings are within the run-to-run variation of the test steps.
- What the extra memory did change in this run is how much request history MockServer keeps for verification and the dashboard: the request log's cap grew from about 63 MB (at most 56,576 entries) to about 129 MB (at most 115,456). How many requests fit depends on their size: larger requests and responses mean fewer.
- Rows marked lower bound (shown with ≥) may have been limited by the load generator, by the highest rate tested, or by something other than MockServer's CPU, so that size can handle at least that much; its per-core and scaling figures are minimums too.
- Leave headroom. These are the highest rates at which latency was still flat, measured with logging reduced below the default, so plan to run below them — and see memory & troubleshooting if you keep a large request history.
Each row is a fresh MockServer container pinned to that many CPU cores (each on its own physical core, with no other test container on it) and limited to that much memory, with the image default heap sizing (no -Xmx). The load generator runs on other cores. Logging is reduced below the shipped INFO default for measurement. Measured 2026-10-01T07:29:24Z · build #544 · MockServer 8.0.1-SNAPSHOT · c5.12xlarge · re-assembled offline from the run's work files, some inputs reconstructed from the run log.
Matcher scaling (time per request match)
Microbenchmark of a single request match against a growing set of literal expectations, worst case (no match, so every candidate is examined): MockServer's candidate index versus a naive linear scan.
| expectations | linear scan | candidate index (default) | speed-up |
| 1 | 0.34 µs | 0.35 µs | — |
| 10 | 2.3 µs | 0.21 µs | ~11× |
| 100 | 21 µs | 0.32 µs | ~67× |
| 1,000 | 218 µs | 1.5 µs | ~147× |
| 5,000 | 1,378 µs | 7.8 µs | ~177× |
Times are microseconds per match. At one expectation the index adds negligible overhead; as expectations grow it keeps matching cheap where a linear scan grows roughly linearly with the number of expectations.
Latency by action
Fixed-rate latency per behaviour — a simple match, a forwarded request, a templated response and a large body — over HTTP/1.1 and over HTTPS with HTTP/2, from the daily single-load-generator run (build #464, c5.12xlarge):
| action | protocol | p50 | p95 | p99 |
| match | HTTP/1.1 | 0.176 | 0.54 | 4.86 |
| forward | HTTP/1.1 | 0.664 | 4.932 | 5.686 |
| template | HTTP/1.1 | 0.251 | 4.831 | 5.071 |
| template_mustache | HTTP/1.1 | 0.332 | 4.988 | 5.33 |
| large | HTTP/1.1 | 4.783 | 5.196 | 5.583 |
| large_1mb | HTTP/1.1 | 4.811 | 8.087 | 9.342 |
| template_javascript | HTTP/1.1 | 1.047 | 5.635 | 9.437 |
| large_file | HTTP/1.1 | 4.469 | 8.352 | 9.607 |
| match | HTTPS + HTTP/2 | 0.329 | 0.966 | 1.68 |
| forward | HTTPS + HTTP/2 | 0.756 | 5.19 | 5.895 |
| template | HTTPS + HTTP/2 | 0.439 | 1.378 | 5.185 |
| template_mustache | HTTPS + HTTP/2 | 0.445 | 5.332 | 6.102 |
| large | HTTPS + HTTP/2 | 4.997 | 5.577 | 6.149 |
| large_1mb | HTTPS + HTTP/2 | 7.535 | 12.534 | 15.331 |
| template_javascript | HTTPS + HTTP/2 | 1.072 | 5.781 | 6.909 |
| large_file | HTTPS + HTTP/2 | 5.215 | 8.857 | 14.044 |
| forward_absolute_proxy | HTTP/1.1 | 0.956 | 1.379 | 1.791 |
| forward_connect_proxy | HTTP/1.1 | 1.239 | 1.728 | 3.343 |
| match_clustered_control | HTTP/1.1 | 0.266 | 4.708 | 44.466 |
| forward_clustered_control | HTTP/1.1 | 0.757 | 5.857 | 55.515 |
| template_clustered_control | HTTP/1.1 | 0.424 | 5.561 | 52.079 |
| template_mustache_clustered_control | HTTP/1.1 | 0.447 | 5.282 | 51.275 |
| large_clustered_control | HTTP/1.1 | 4.621 | 5.591 | 43.932 |
| crossing_clustered_control | HTTP/1.1 | 0.421 | 0.637 | 0.679 |
| match_clustered | HTTP/1.1 | 0.268 | 4.464 | 24.31 |
| forward_clustered | HTTP/1.1 | 0.736 | 29.731 | 192.31 |
| template_clustered | HTTP/1.1 | 0.449 | 33.72 | 184 |
| template_mustache_clustered | HTTP/1.1 | 0.448 | 28.855 | 205.735 |
| large_clustered | HTTP/1.1 | 4.6 | 28.526 | 115.188 |
| crossing_clustered | HTTP/1.1 | 1.78 | 2.583 | 2.76 |
Latencies are in milliseconds, measured at a fixed arrival rate on a six-core instance.
Tuning for your workload
The defaults are tuned for general use. For sustained high throughput, large payloads, or long-running instances, the most effective levers are memory/log sizing and a few scalability settings — covered in detail below. The single most common cause of an instance slowing down over time is the in-memory request log filling the heap; see memory & troubleshooting.
Concurrent connection limits are set by the OS and the JVM, not by MockServer
If you are load-testing MockServer, or running it in front of many long-lived keep-alive clients, the ceiling you hit first is almost never MockServer's. It is one of two operating-system limits, and on macOS the first of them is invisible — the JVM quietly overrides what you set. Both apply to the machine running MockServer and the machine driving load at it, independently: whichever hits its limit first is the one that fails.
1. File descriptors — one per connection
Every established TCP connection consumes a file descriptor. The JVM does not simply inherit the limit your shell reports, and what it does instead is the opposite on the two platforms:
| Platform | What the JVM does by default | With -XX:-MaxFDLimit |
|---|---|---|
| macOS | Caps the limit at 10,240 (the BSD OPEN_MAX), however high ulimit -n reads. A shell reporting 60,000 still gives a JVM 10,240. It also raises a lower limit up to 10,240. | Uses the shell's soft limit unchanged — 60,000 stays 60,000, and 2,048 stays 2,048. |
| Linux | Raises the soft limit to the hard limit automatically. A service started with a soft limit of 1,024 and a hard limit of 1,048,576 gets 1,048,576 — no flag needed. | Makes things worse. Disables that automatic raise, leaving the soft limit: 1,024. |
So the correct action is different on each platform, and the flag is not a portable "more file descriptors" switch:
- macOS — you need both. Raise the shell limit ulimit -n 65536 and start the JVM with -XX:-MaxFDLimit. The flag alone gives you whatever the shell had; the shell alone is ignored above 10,240.
- Linux — do not pass the flag. Make sure the hard limit is high and the JVM will take it: LimitNOFILE=1048576 in a systemd unit, --ulimit nofile=1048576:1048576 for docker run, or the container runtime's default in Kubernetes (usually already high).
How the limit announces itself: the JVM cannot create the socket at all, so you see Too many open files, or from Netty ChannelException: Unable to create Channel from class ... NioSocketChannel. That is a resource limit on the machine reporting it — not MockServer refusing the connection, and not the network.
2. Ephemeral ports — a limit on the client side only
A connection is identified by (source IP, source port, destination IP, destination port). A load generator opening many connections to one MockServer draws a source port from the ephemeral range for each, so the range size caps how many it can hold at once:
| Platform | Default range | Usable connections | How to widen it |
|---|---|---|---|
| macOS | 49152–65535 | ~16,000 | sudo sysctl -w net.inet.ip.portrange.first=32768 (about 32,700 ports, matching Linux's default start) |
| Linux | 32768–60999 | ~28,000 | sysctl -w net.ipv4.ip_local_port_range="10000 65535", or docker run --sysctl net.ipv4.ip_local_port_range="10000 65535" |
Do not lower the start of the range too far. Ports below it are where fixed services bind, and an ephemeral connection that happens to take one can stop a service binding it later. Starting at 32768 matches Linux's own default and stays clear of the registered-port range.
These sysctl -w changes do not survive a reboot. To make one permanent, add it to /etc/sysctl.conf on macOS, or a file under /etc/sysctl.d/ on Linux. A docker run --sysctl flag applies to that container only, and needs repeating for each run.
Which of these we verified: the file-descriptor behaviour in the table above was measured on both platforms, as were the default port ranges and the connection counts below. The Linux sysctl/--sysctl change was tested and takes effect. The macOS sysctl change is the documented mechanism but we have not measured it, because it is a system-wide change to a developer machine.
Giving MockServer more ports does not help. It is tempting to assume that spreading connections over several server ports gives each its own source-port space. On macOS it does not — the range is global to the client, and measurement confirms it: 15,511 connections to one server port, 15,609 spread over four, a difference of 1%. To exceed the range you need more client source addresses (extra loopback aliases, or more load-generator machines), not more server ports — and that is measured, not predicted: driving from two source addresses carried 16,000 connections, past the 15,511 single-address wall. Capacity is per source address even though the ephemeral port counter is global, so each address contributes its own range.
Connections arriving in a burst need accept-queue room. A third OS limit sits beside
file descriptors and ephemeral ports, and it announces itself differently from both. While MockServer
accepts connections, the kernel parks completed handshakes in a queue whose depth is
mockserver.soBacklog (default 1024), capped by
net.core.somaxconn on Linux or kern.ipc.somaxconn
on macOS. When that queue fills, the kernel does not refuse the connection — it silently drops the
handshake packet and the client retransmits after its initial timeout, about one second on Linux. The
symptom is therefore a median latency of roughly a second with zero errors, which looks like a
slow server rather than a limit being hit. Raise both the property and the OS value together; in Docker
the container has its own somaxconn, so
--sysctl net.core.somaxconn=16384 is needed as well as the host setting.
This only affects connection establishment: traffic over existing keep-alive connections never
touches the queue.
Closed connections keep their port for a while. A socket you close sits in TIME_WAIT holding its source port — 30 seconds on macOS (twice the 15-second net.inet.tcp.msl), 60 seconds on Linux by default. So a test that opens 10,000 connections, closes them, and immediately opens 10,000 more is asking for 20,000 ports. Linux can reuse those sockets for new outbound connections: net.ipv4.tcp_tw_reuse defaults to 2, which enables reuse for loopback traffic only — set it to 1 to enable it for all traffic. macOS has no equivalent, so there you simply wait it out between runs.
How this limit announces itself: Can't assign requested address / BindException. Note that it looks nothing like the file-descriptor error above, and the fix is entirely different — if you see one and apply the other's remedy, nothing will change.
What we measured
On an Apple M3 Max laptop (macOS, Zulu JDK 21), driving a MockServer in a second JVM on the same machine: with the JVM's default file-descriptor behaviour the driver stopped at 9,977 connections — the 10,240 cap, not MockServer. (The few hundred missing are the descriptors the JVM and the OS already hold for their own use, so the usable ceiling always sits a little below the raw limit.) With -XX:-MaxFDLimit on both JVMs it reached 15,511, stopping at the ephemeral port range instead. Holding 16,000 idle keep-alive connections — reached by spreading them over two source addresses — request latency on a separate connection was indistinguishable from the same measurement with no connections held — run-to-run variation exceeded any effect of the held connections. MockServer itself was not the limiting factor at any point; both ceilings belonged to the machine.
Clustering MockServer
MockServer supports a very high request throughput from a single node. When you need high availability, horizontal scale beyond one node, or zero-downtime rolling updates, MockServer can run as a cluster so that all nodes share state.
There are two ways to cluster MockServer. For the full deployment guide — architecture diagrams, the -clustered image, Docker Compose and Helm examples, and a decision table — see Centralised Deployment.
Infinispan state backend (recommended)
The production-grade approach uses an embedded Infinispan state backend that replicates state synchronously across all nodes over JGroups. Expectations, scenario state, CRUD entity stores, and chaos profiles are visible on every node immediately after any write, and per-expectation Times match limits are enforced cluster-wide. No shared filesystem is required.
Clustering requires the mockserver-state-infinispan module on the classpath. The pre-built -clustered Docker image (mockserver/mockserver:clustered-9.2.0) bundles this module and its JGroups dependencies; the default image does not. Give a clustered node at least 768 MiB of container memory (1 GiB is a comfortable starting point): Infinispan and JGroups need more memory outside the heap than a single node, and at the 512 MiB that suits the other images a clustered node under load runs out of memory. It is enabled with three properties:
| Property | Env var | Default | Description |
|---|---|---|---|
| mockserver.stateBackend | MOCKSERVER_STATE_BACKEND | memory | Set to infinispan to enable the clustered backend (requires the mockserver-state-infinispan module). |
| mockserver.clusterEnabled | MOCKSERVER_CLUSTER_ENABLED | false | Set to true to activate the JGroups transport and state replication across nodes. |
| mockserver.clusterName | MOCKSERVER_CLUSTER_NAME | mockserver-cluster | Cluster identifier. All nodes that should share state must use the same value. |
Note: as with the file-based approach below, the request log and verification ring buffer are node-local by default — a verify() call only checks the node that received it. To make verify and retrieval of recorded requests aggregate across the whole cluster, enable cluster verify fan-in: set clusterVerifyFanIn=true and list the other nodes' base URLs in clusterVerifyFanInPeers (for example http://mockserver-1:1080,http://mockserver-2:1080). Each node then asks its peers for their local records and counts matching requests across the whole fleet before evaluating the verification (count-based verifications such as exactly/atLeast/atMost only; ordered verify sequences and the live dashboard traffic view remain node-local). If your control plane is authenticated (bearer token, JWT, or OIDC), also set clusterFanInPeerAuthToken (the same value on every node) to the credential each node should present to its peers — for example Bearer <jwt> — otherwise the cross-node queries are rejected and the verify/retrieve fails. The Centralised Deployment page covers this constraint and the full Docker Compose and Kubernetes/Helm setup.
Shared filesystem (simpler alternative)
A simpler alternative that needs no extra module is to have several non-clustered nodes share their expectations through a common read-write filesystem and identical persistence configuration. State is reconciled by writing to and re-reading the shared file rather than by live replication, so propagation is eventual rather than synchronous. This suits modest fleets where a shared volume is already available.
To create a file-based MockServer cluster all instances need to:
- share a read-write file system i.e. same physical / virtual machine, NFS, AWS EFS, Azure Files, etc
- configure identical expectation initialiser and expectation persistence
- bind to a free port i.e. separate ports if on same physical / virtual machine
Each node could be configured as follows (adjusting the port as necessary):
MOCKSERVER_WATCH_INITIALIZATION_JSON=true \
MOCKSERVER_INITIALIZATION_JSON_PATH=mockserverInitialization.json \
MOCKSERVER_PERSIST_EXPECTATIONS=true \
MOCKSERVER_PERSISTED_EXPECTATIONS_PATH=mockserverInitialization.json \
java -jar ~/Downloads/mockserver-netty-9.2.0-no-dependencies.jar -serverPort 1080 -logLevel INFO
or
java \
-Dmockserver.watchInitializationJson=true \
-Dmockserver.initializationJsonPath=mockserverInitialization.json \
-Dmockserver.persistExpectations=true \
-Dmockserver.persistedExpectationsPath=mockserverInitialization.json \
-jar ~/Downloads/mockserver-netty-9.2.0-no-dependencies.jar -serverPort 1080 -logLevel INFO
Memory Tuning Guide
MockServer stores expectations and log entries in memory using ring buffers. The two most important settings that affect memory usage are maxExpectations and maxLogEntries. Each HTTP request processed by MockServer generates 2-3 log entries (the request itself, the expectation match result, and the response). Because maxLogEntries caps the entry count and cannot see how large each entry is, a workload with large bodies is bounded instead by maxEventLogSizeInBytes — a byte budget that is on by default, and the defaults (this budget plus a separate cap on entries still waiting to be logged) keep the whole log at or below about a quarter of the heap ceiling, so a few large bodies cannot exhaust the heap through a count-only cap. At WARN and quieter the default keeps about a third as much as a seventh of the heap would, so that under heavy load kept entries do not slow the garbage collector; raise maxEventLogSizeInBytes if you need more request history for verify or the dashboard.
Both settings have automatic defaults derived from the JVM heap size (the -Xmx maximum, as the JVM reports it, whichever garbage collector you use), so the default is the same for every MockServer instance in a JVM regardless of how much memory is already in use. On larger heaps the default maxLogEntries scales up to 250,000 entries, so more request history is retained before the oldest entries are overwritten; the byte budget (maxEventLogSizeInBytes) still bounds total memory. The table below provides recommended values if you want to override the defaults for different heap sizes:
| JVM Heap Size | maxExpectations | maxLogEntries | Approx HTTP Requests Retained |
|---|---|---|---|
| 256 MB | 1,000 | 5,000 | ~1,500 - 2,500 |
| 512 MB | 2,000 | 15,000 | ~5,000 - 7,500 |
| 1 GB | 5,000 | 40,000 | ~13,000 - 20,000 |
These are conservative estimates assuming typical request/response sizes of 1-5 KB. Large request or response bodies will consume more memory per entry.
Tips for reducing memory usage:
- Reduce maxLogEntries to limit the number of stored request/response log entries
- Reduce maxExpectations if expectations contain large response bodies
- Increase JVM heap size (-Xmx) to give the garbage collector more headroom
- Use outputMemoryUsageCsv to monitor actual heap usage and tune values accordingly
Docker example with memory-constrained settings:
docker run -d --rm -p 1080:1080 \
--env MOCKSERVER_MAX_EXPECTATIONS=1000 \
--env MOCKSERVER_MAX_LOG_ENTRIES=5000 \
mockserver/mockserver
Introduces a delay (in milliseconds) before protocol detection on new TCP connections. This can be used to simulate slow connection establishment, such as when testing client timeout handling or connection pooling behaviour under latency.
Type: long Default: 0
Java Code:
ConfigurationProperties.connectionDelayMillis(long millis)
Configuration.connectionDelay(Delay delay)
System Property:
-Dmockserver.connectionDelayMillis=...
Environment Variable:
MOCKSERVER_CONNECTION_DELAY_MILLIS=...
Property File:
mockserver.connectionDelayMillis=...
Example:
-Dmockserver.connectionDelayMillis="500"
Troubleshooting: MockServer Becomes Slow or Unresponsive
If MockServer appears to freeze, hang, or become progressively slower under sustained load, the most likely cause is memory pressure from log entry accumulation. This section explains why it happens and how to fix it.
If instead connections are being refused or failing to open, this is not the section you want. Two errors in particular are operating-system limits rather than MockServer, and neither is fixed by any MockServer setting:
- Too many open files, or Netty's ChannelException: Unable to create Channel — the process has run out of file descriptors, one of which every connection needs. On macOS the JVM caps this at 10,240 however high you set ulimit -n, and the fix differs between macOS and Linux.
- Can't assign requested address / BindException — the client machine has run out of ephemeral source ports. This one is never the server.
See Concurrent connection limits are set by the OS and the JVM for the cause of each and what to change.
Why Does MockServer Slow Down?
MockServer keeps a record of every request it processes and every response it sends. This is a deliberate feature, not a side-effect of logging — it is what makes verify, retrieveRecordedRequests, and the dashboard work. Because these entries exist to support verification, they are stored regardless of the configured log level. Lowering the log level reduces the diagnostic output written to stdout and cuts per-matcher allocation on the matching hot path (see the note on log level below), but the request and response records themselves are always written to the in-memory store. Many users reach for the log level first when they notice slowness; that will not fix this cause.
The store is bounded: when it fills up, the oldest entries are evicted to make room for new ones. Under sustained load, entries are evicted and replaced continuously, and the JVM must reclaim the memory those evicted entries held. It does this by periodically stopping all application threads briefly — a garbage collection pause. Most requests land between pauses and see very low latency. But the small fraction of requests that arrive just as a collection starts must wait for it to finish. This is why a load test can show a comfortable median alongside an alarming p99: the median reflects the majority of requests that were never affected, while the tail captures the unlucky few that waited behind a pause.
Request rate, not response body size, is the primary driver. Even a response of just a few bytes creates a log entry dominated by request metadata, headers, and match context — each entry occupies roughly 4–10 KB of heap regardless of whether the body is 6 bytes or 6 kilobytes. At high request rates the ring buffer turns over quickly, triggering eviction and collection more and more often. Measured on a deliberately heap-constrained server to make collections visible: garbage collection consumed 6.6% of wall-clock time at around 48,000 requests per second, with pauses averaging around 56 ms each. The number of collections rose almost perfectly in step with request rate; each individual pause stayed roughly the same length. More load means more pauses, not longer ones — but as the rate climbs, more requests fall inside them and the tail grows. A workload returning a 6-byte response at 8,000 req/s showed a sub-millisecond median alongside a p99 in the tens of milliseconds. Response body size shifts the numbers but does not change the pattern. The constrained-heap figures here are from a test server sized to make collections frequent and visible; on a larger production heap the allocation volumes per minute are the same, but each collection comes less often against a bigger budget.
There is a further practical point. At around 40,000 req/s, the default store holds roughly one second of history — at 8,000 req/s the same store holds roughly five seconds. At high throughput, the store is costing latency tail to retain a window that most users could never query in time anyway. This is the sharpest argument for reducing maxLogEntries when running at high rates: the GC pressure drops, and the history window you give up is already far too short to be useful.
The table below shows log allocation throughput at a range of rates and body sizes. The high-rate figure is derived from the per-entry cost above at its lower bound (two entries of 4 KB per request) and is therefore a floor, not a ceiling — larger headers or a third entry per request push it higher. The high-rate row shows that at 8,000 req/s even a tiny response generates the better part of four gigabytes of allocation per minute — far more than any of the body-size comparisons.
| Request Rate | Response Body Size | Log Data Generated Per Minute |
|---|---|---|
| 1 req/s | 1 KB | ~1 MB |
| 10 req/s | 1 KB | ~12 MB |
| 10 req/s | 100 KB | ~120 MB |
| 1 req/s | 1 MB+ | ~120 MB |
| 8,000 req/s | ~10 bytes (body barely matters; metadata dominates) | ~3,750 MB or more |
Large response bodies increase the per-entry cost and make the problem worse at any given rate — a single expectation returning a very large response at even a modest rate can generate a significant volume of log allocation. But body size is a multiplier on a pattern that is already present at high request rates with tiny responses.
Note: Expectations with large response bodies also consume heap proportionally (e.g., a 50 KB response body results in ~55-75 KB per stored expectation). If you have many expectations with large bodies, reduce maxExpectations as well.
How To Fix It
Apply one or more of the following, depending on your use case:
| Fix | When To Use | Trade-off |
|---|---|---|
| Increase JVM heap size | Gives the GC more headroom so it collects less often — useful at high request rates and with large response bodies | Uses more container/host memory |
| Reduce maxLogEntries | The single most effective fix at high request rates — fewer entries to evict means less collection work. At high rates (thousands of req/s) the default store already holds only seconds of history, so you are giving up very little by bounding it tightly | Fewer requests available for verification |
| Reduce maxExpectations | When expectations contain large response bodies | Fewer expectations can be stored simultaneously |
| Switch to ZGC (-XX:+UseZGC) | Heap ≥ 4 GB and matcher latency matters — typically gives single-digit-millisecond GC pauses where G1 commonly sits in the 50–200 ms range under sustained allocation | Fixed memory overhead (less attractive below ~4 GB); set -Xms and -Xmx to the same value (e.g. -Xms4g -Xmx4g) so the heap is pre-committed |
Note on GC selection: the MockServer Docker images already run generational ZGC by default (from v8.x) — you get the lower tail latency below without setting anything, and can revert with JAVA_TOOL_OPTIONS=-XX:+UseG1GC. The guidance here applies mainly to the standalone JAR, where the JDK default collector (G1) is used unless you pass -XX:+UseZGC yourself. ZGC has been production-ready since Java 17, and -XX:+UseZGC can cut p99 matcher latency for deployments with deep ring buffers (high maxLogEntries) or large heaps, by holding stop-the-world pauses to the single-digit-millisecond range. Which ZGC you get depends on the Java version. From Java 23 onwards ZGC defaults to generational and -XX:+UseZGC alone selects it — that is what the MockServer Docker images give you, since they bundle Java 25 or later (the -clustered image bundles Java 21). On Java 22 and earlier, including Java 21, the same flag gives the older non-generational collector unless you add -XX:+ZGenerational; on Java 17 only the non-generational collector exists. Java 24 removed the non-generational mode altogether. Generational ZGC reaches noticeably shorter pauses than the 1–5 ms typical of the non-generational one. The separate -XX:+ZGenerational flag was removed in Java 24 — passing it there does no harm, but it is ignored with a warning, so drop it. For a standalone-JAR sidecar with a very small heap the JDK default collector is fine — ZGC carries a fixed memory overhead. On the Docker images, though, ZGC is the default even on small containers: testing showed it lower request tail latency on the default container heap without OOM-killing a 512 MB or 1 GB container (under load it grows to its full heap and keeps it while the load lasts, in memory the kernel cannot reclaim, and it holds roughly 60–80 MB more idle memory than the previous default; the images’ 45%-of-limit heap cap leaves room for that down to the 512 MB floor, or 768 MB for a -clustered node with the Infinispan backend), so the earlier “only worth it above ~4 GB” guidance does not apply to the way the images are configured. In containerised deployments using ZGC, size the container memory limit at ~1.5× your -Xmx value to leave headroom for JVM overhead (code cache, metaspace, thread stacks) and Netty’s direct buffer pool. Note that ZGC multi-maps the same physical pages for its coloured-pointer scheme; under some cgroup RSS-accounting modes those pages are counted multiple times against the container limit, so the process can be OOM-killed even though the actual physical footprint fits (e.g. -Xmx4g → --memory=6g). Shenandoah is not recommended — it is not available in every JDK distribution, so ZGC is the simpler choice.
Note on logLevel and disableLogging: the received-request and response log entries — the ones a verify reads — are always stored regardless of log level, so lowering the level never breaks verification. But the per-expectation match / did-not-match diagnostic entries are stored only at INFO or more verbose: at WARN they are neither produced nor retained. So on a workload with many expectations, running at WARN genuinely reduces the number of entries retained per request (a request that scans N expectations before matching can log up to N diagnostic entries at INFO), which lowers memory and the pressure on the entry-count limit (maxLogEntries) without affecting what verify can find — the default byte budget is the same at WARN as at INFO, though smaller than at DEBUG /TRACE, so coming from those levels on a workload where that budget is what evicts, raise maxEventLogSizeInBytes too — the cheapest safe lever when a memory-constrained server is under load. It does not reduce the memory held by large request/response bodies, which are on the always-retained entries; for those, the byte budget (maxEventLogSizeInBytes, on by default) bounds the kept entries, and entries still waiting to be logged are capped at the larger of that budget and a seventh of the heap ceiling (a twelfth at DEBUG /TRACE) — the server drops waiting entries rather than OOM-ing when that cap is reached (see the heap-sizing section below). WARN drains the waiting entries faster (and, from DEBUG or TRACE, widens their cap by about 70%), so it remains the cheapest lever against drops for large-body workloads; the default kept history is the same at INFO and WARN (a twentieth of the heap ceiling, against a twelfth at DEBUG /TRACE), so raise maxEventLogSizeInBytes if you need more of it. maxLoggedBodyBytes (per-entry body truncation) reduces the deque's steady-state body footprint — truncation runs in the consumer, after the ring, so it does not reduce the ring's in-flight byte contribution — and breaks verification against body content — a verify that matches on body will fail against a truncated entry; verification by path, method, and header is unaffected. disableLogging and disableSystemOut only silence log output — they do not stop requests being recorded, so neither reduces heap or GC pressure. Reaching for either when you see latency or memory pressure will not help; use the levers above (log level, maxLogEntries, maxEventLogSizeInBytes, maxLoggedBodyBytes) instead.
Note on logLevel and matching throughput: beyond the retained-entry saving above, running below INFO also cuts transient per-request allocation on the matching hot path. At INFO, every request that scans expectations builds and logs a diagnostic “matched”/“did not match because…” message per matcher; below INFO that work — the per-matcher log entry and the human-readable “because” string assembly — is skipped entirely. For a deployment with many expectations under sustained load this is the single largest matching-path allocation, so running at WARN noticeably cuts allocation churn and GC pressure (it does not change which requests match, only whether the diagnostic narrative is produced). Lowering the level therefore helps on two fronts — fewer retained diagnostic entries (memory) and less transient allocation (throughput/GC) — while leaving the received-request/response entries that verify depends on fully intact.
Note on logLevel and high request rates: logging at INFO is produced by a single background thread, and on a busy server that thread can fall behind the rate requests arrive — in our own load tests we estimate this starting roughly from 8,000–10,000 requests/second on a 6-core server with four expectations, where the request matches the last one (about six log entries per request: the match attempt against each of the first three, the match, and the recorded request/response). Each expectation checked before a match adds another log entry, so with more expectations the same server starts dropping at a lower request rate — roughly half as many requests per second with ten (again matching the last one). Past that point, log entries are dropped, which can mean a request is missing from retrieveRecordedRequests, from the dashboard, or from a verify — by default (failVerificationOnEvictedLog), an upper-bound verify (never(), atMost(n), exactly(n), once(), between(a,b)) fails rather than passes once this happens, naming the drop in its failure message, until the log is reset or cleared entirely. A once-only WARN (“Log event ring buffer full — dropping log events…”) naming the remedies below is logged the first time this happens after each reset regardless of metricsEnabled, unless logging or system-out is disabled. For high-throughput tests that call verify, run at WARN or ERROR instead of the INFO default — it keeps the entries verify needs while skipping the per-expectation diagnostics that saturate the logging thread. To keep INFO, set maxLoggedMatchFailures (for example to 10) instead: it caps how many of those diagnostics each request writes, and leaves which expectation matches and the closest-match entry unchanged. You can also watch this happening with metricsEnabled set to true: the mock_server_dropped_log_events Prometheus counter (these drops carry the label reason="ring_full") and the dashboard's log-pressure banner both need it, and show nothing while it is off (the default).
Recommended Configurations
High-throughput workloads (any response size — this applies regardless of body size; request rate is the driver):
docker run -d --rm -p 1080:1080 \
-e MOCKSERVER_MAX_LOG_ENTRIES=5000 \
mockserver/mockserver
Large response bodies (e.g., responses >100 KB):
docker run -d --rm -p 1080:1080 \
-e JAVA_TOOL_OPTIONS="-XX:+UseZGC -Xmx1g" \
-e MOCKSERVER_MAX_LOG_ENTRIES=1000 \
-e MOCKSERVER_MAX_EXPECTATIONS=100 \
mockserver/mockserver
Maximum throughput, minimal memory (verification limited to most recent requests):
docker run -d --rm -p 1080:1080 \
-e JAVA_TOOL_OPTIONS="-XX:+UseZGC -Xmx512m" \
-e MOCKSERVER_MAX_LOG_ENTRIES=100 \
-e MOCKSERVER_LOG_LEVEL=WARN \
mockserver/mockserver
MOCKSERVER_LOG_LEVEL=WARN drops the per-matcher diagnostic logging on the matching hot path (see the note above), which is the largest matching-path allocation when many expectations are registered. If your matchers rely heavily on regular expressions and your patterns and inputs are trusted, also add -e MOCKSERVER_REGEX_MATCHING_TIMEOUT_MILLIS=0 to evaluate regexes inline and skip the per-regex thread hand-off (this removes the catastrophic-backtracking guard — see Regex Matching Timeout).
Configuring JVM Heap Size
The MockServer Docker image caps the JVM heap at 45% of the container's memory limit (-XX:MaxRAMPercentage=45.0), so the in-memory ring buffers size off a bounded heap. Always run the container with an explicit memory limit of at least 512 MiB (768 MiB for a -clustered node with the Infinispan backend) (for example --memory=2g, or resources.limits.memory in Kubernetes) — without one the JVM sizes its heap off the host's total memory, and the default ring sizes (maxLogEntries and maxExpectations, which derive from available heap) scale up with it, so MockServer can be OOM-killed under load.
Sizing the container for your workload. The heap is only part of what the Java process occupies, and — this is the part that trips people up — the rest is not a fixed fraction of the heap. It is largely additive and driven by load, not by heap size. Under sustained load a 1,536 MiB heap was measured occupying 2,271 MiB of real memory; the 735 MiB difference breaks down as:
- class metadata (metaspace) and the JIT code cache — about 133 MiB, and roughly constant regardless of heap;
- one stack per thread — tens of MiB, set by the thread count (which tracks CPU cores), not the heap;
- the garbage collector's own bookkeeping — this part does scale with the heap;
- and the largest, most variable piece: the pool of direct (off-heap) network buffers Netty uses for request and response bodies, whose size is driven by concurrency and body size rather than by the heap, and which is capped at a quarter of the heap (see Off-heap buffer limit below).
None of that is counted by -Xmx, and most of it does not shrink when you shrink the heap. So the practical model is footprint ≈ heap + overhead, where the overhead is dominated by heap-independent, load-driven terms. A 75% cap could not fit — at 75% of a 2 GiB limit the heap alone (1,536 MiB) plus this overhead exceeded the limit and the kernel killed the process. 60% did not fit at the small end either: the Z garbage collector (the images' default) under load grows to its full heap and keeps it while the load lasts, in memory the kernel cannot reclaim, so at 512 MiB a 308 MiB heap plus about 150 MiB of other JVM memory and about 20 MiB of kernel socket buffers for ~4,800 connections left almost nothing, and the container was killed under load. That is why the default is 45%: at 50% the container survived, but in some runs peaked above 90% of its limit.
The convenient “budget about 1.5× the heap” shorthand is just what heap + overhead happened to work out to at around a 1.5 GiB heap under sustained load (1,536 + 735 ≈ 1.48×). It is a reasonable floor for heaps of roughly a gigabyte or more, but it gets progressively worse as the container gets smaller: the fixed overhead stays roughly the same while the heap shrinks, so the effective multiple climbs and a larger fraction of the container goes to memory the percentage cap cannot bound. Do not extrapolate 1.5× down to a small container — and note the 735 MiB itself is a conservative upper bound (the measurement run carried profiling instrumentation, which uses native memory) that rises further with more concurrent connections and larger bodies. The heap the 45% cap gives you, and what that leaves for the overhead, at common container sizes:
| Container memory limit | Heap at the 45% default | Left for non-heap + native | Realistic use |
|---|---|---|---|
| 256 MiB | ~126 MiB | ~130 MiB | Not supported with the defaults — the fixed overhead alone is larger than this. Only with a small explicit -Xmx and light load. |
| 512 MiB | ~232 MiB | ~280 MiB | The supported minimum (not for a -clustered node with the Infinispan backend, which needs 768 MiB). Survives sustained load on one core (tested at 16,000 req/s over ~4,500 connections with small bodies, peaking at 79–83% of the limit); keep bodies to a few KB. |
| 1 GiB | ~462 MiB | ~562 MiB | Moderate load and body sizes. |
| 2 GiB | ~922 MiB | ~1126 MiB | Comfortably above the ~735 MiB of overhead measured under sustained load — recommended for high request rates. |
| 4 GiB | ~1844 MiB | ~2252 MiB | High concurrency and/or large bodies. |
The “left for non-heap + native” column is simply limit − heap — the budget the percentage cap leaves for everything -Xmx does not count. Compare it against the overhead your traffic actually produces: the ~735 MiB measured is for one sustained-load profile and grows with concurrency and body size (the same effect that makes 1.5× a floor, not a constant). A container is safe when this column comfortably exceeds your measured overhead — which is why 2 GiB is recommended at high request rates and 512 MiB is a floor, not a target. To size for your own workload, watch container RSS or enable CSV memory tracking (see Monitoring Memory Usage below) under representative load rather than trusting a multiple of the heap; and when in doubt, give the non-heap side room, because it is the part the heap-percentage cap cannot bound.
If your container is killed with exit code 137. When the total footprint exceeds the container limit, the Linux kernel kills the process — the container exits with code 137 and OOMKilled: true, and there is no Java stack trace and no OutOfMemoryError in the logs, because the JVM did not fail — it was killed from outside. Confirm it with:
docker inspect --format '{{json .State}}' <container>
# "OOMKilled": true, "ExitCode": 137
The fix is a larger memory limit or a smaller heap percentage — not a bigger heap. Seeing 137 with plenty of -Xmx headroom is the classic trap: raising -Xmx makes it worse, because the heap was never the whole footprint. Either raise --memory to cover the heap plus the load-driven overhead above, or lower the cap.
Off-heap buffer limit. Whenever MockServer runs as its own process (the Docker images, the standalone jar, the forked Maven plugin and the launchers), the off-heap buffers Netty uses for request and response bodies are limited to a quarter of the maximum heap, and at least 64 MiB. At the images' default heap that is 64 MiB in a 512 MiB container and 115 MiB in a 1 GiB one. Without the limit these buffers could grow as large as the heap itself, and the container could be killed with exit code 137. What counts against it:
- Large downloads by slow clients count very little. MockServer sends a large response a slice at a time as the client reads it, so each such client holds about 64 KB.
- Forwarded and proxied responses are held in full while MockServer collects them, and so are request bodies while they are being uploaded. Several large forwards or uploads at once can reach the limit: at 64 MiB, one forwarded 49 MiB response succeeds, but two at the same time both fail.
- Responses through a CONNECT or SOCKS tunnel are also held in full on their way to the client.
When the limit is reached, whichever connection needs a buffer next is closed — which may be an unrelated request, not the one holding the memory — and MockServer keeps serving everything else. A client whose request was being forwarded gets a 502 response; a client whose own connection was the one closed sees the connection drop (for example a connection reset, or an incomplete upload). The log shows direct memory limit (io.netty.maxDirectMemory) reached. If your traffic needs more, raise the limit with -XX:MaxDirectMemorySize or -Dio.netty.maxDirectMemory=<bytes> — in Docker, through JAVA_TOOL_OPTIONS, keeping the image's -XX:+UseZGC, for example -e JAVA_TOOL_OPTIONS="-XX:+UseZGC -XX:MaxDirectMemorySize=256m". MockServer leaves either setting as you set it, and logs the limit in effect at start-up in a line beginning netty direct memory limit. Leave room for the limit in the container on top of the heap.
Overriding the default, and what you trade. The 45% cap only applies when you have not set an explicit heap. To change it you have two levers, and it is important not to confuse them:
- To use a different percentage of the container limit, rebuild or re-run so the JVM sees a different -XX:MaxRAMPercentage. Note the footgun: passing a different -XX:MaxRAMPercentage via JAVA_TOOL_OPTIONS does not work — the image's entrypoint applies its own value last, so it always wins.
- To pin an absolute heap size, set an explicit -Xmx via JAVA_TOOL_OPTIONS. Be aware that setting -Xmx (or any heap sizing via JAVA_TOOL_OPTIONS) disables MaxRAMPercentage entirely — the percentage cap no longer applies at all. A user who sets one thinking it tunes the other gets neither, so choose one mechanism, and still size the container against the heap plus the load-driven overhead above — not a fixed multiple of the heap:
docker run -d --rm -p 1080:1080 \
-e JAVA_TOOL_OPTIONS="-XX:+UseZGC -Xmx512m" \
mockserver/mockserver
When running with docker compose:
services:
mockServer:
image: mockserver/mockserver
ports:
- "1080:1080"
environment:
JAVA_TOOL_OPTIONS: "-XX:+UseZGC -Xmx512m"
MOCKSERVER_MAX_LOG_ENTRIES: "5000"
When running as a standalone JAR:
java -Xmx512m -jar mockserver-netty.jar -serverPort 1080
How large bodies affect this choice. Whether -Xmx512m is adequate depends entirely on the bodies your traffic carries. Under sustained load at the default INFO log level, a 512 MB heap handled 1 KB request bodies — 200,000 requests served with peak heap at 479 of 512 MB. With the ring buffer’s in-flight bytes now bounded by an in-flight cap (a seventh of the heap ceiling, a twelfth at DEBUG /TRACE), larger bodies survive too: at INFO with 256 KB bodies, all 200,000 requests completed (peak 443 MB); at INFO with 64 KB bodies, all 200,000 requests completed. These runs were measured on earlier September 2026 builds whose INFO budget was larger than today's default of a twentieth of the heap ceiling (a quarter for the 1 KB run, an eighth for the 64 KB and 256 KB runs), and before later fixes to how completely the budget counts each entry, so the log held more memory in those runs than the current default allows. They were measured with a single expectation — a best case that generates roughly three log entries per request. A suite with many expectations generates more log entries per request and will exhaust the ring sooner, so treat these as upper bounds, not floors.
Why it now survives, and what happens under extreme pressure. The previous failure path was the LMAX Disruptor ring: requests were published to it holding their full body, and the byte budget only applied after an entry was consumed into the deque. At INFO, the consumer is slow (it formats every entry to system-out), so the ring backed up with full bodies before the budget could apply — exhausting the heap before any eviction ran. The ring’s in-flight bytes are now bounded too, by the larger of maxEventLogSizeInBytes and a heap-derived cap (a seventh of the heap ceiling, a twelfth at DEBUG /TRACE): once body bytes waiting in the ring would exceed it, new entries are dropped rather than admitted. A second inflation source has also been eliminated: previously, each JSON-body entry was retaining a parsed Jackson node tree roughly five times its raw body size for its whole life in the deque, but the weigher counted only the raw bytes — so a server told to hold 256 MB of bodies could hold well over a gigabyte and die of heap exhaustion while the budget believed it had room. Those trees are now built transiently at render time only and are not retained on the entry: measured on 20,000 retained entries, actual heap dropped from 429 MB to 61 MB with bodies unchanged. The budget therefore now tracks retained memory far more closely. The weigher was also made honest about the memory each entry holds beyond its body: it now counts the header bytes and a fixed per-entry overhead for the log entry and each request/response object it retains, not the raw body bytes alone. Measured on live heaps, this brought real retention from about 2.6× the budget at WARN and 4.8× at INFO down to about 2× and 3×. Later fixes removed the remaining copies of each body that the budget could not see (the decoded string kept alongside the raw bytes, and extra copies held by forwarded and templated-echo entries). Re-measured in September 2026 under both the G1 and ZGC garbage collectors, real retention is now 0.50–0.80× the counted budget at WARN and 1.04–2.03× at INFO once the log has caught up, rising to 0.97× and 2.31× when the server receives more traffic than it can log. The entries still waiting to be logged, which hold up to another 0.72× of their cap, are capped separately, and the default divisors are set so both parts together stay at or below about a quarter of the heap ceiling. That INFO gap was the formatted log message, which embeds the body as text; an entry that quotes a request or response no longer keeps it, and re-measured in October 2026 kept entries at INFO held 0.70–1.00× their counted size on JSON and proxy traffic, so the waiting-entries cap at INFO is now the same seventh of the heap ceiling as at WARN; DEBUG and TRACE were not re-measured and keep a twelfth. Because the old accounting under-counted, the same maxEventLogSizeInBytes value now retains fewer entries — the budget evicts sooner, which is the bound doing what it claims; raise it if you relied on the previous, larger retention for a given budget. Size against the multiple, not the budget alone, and measure against your own workload — the multiple depends on how large your bodies and headers are. Under enough sustained pressure MockServer drops entries rather than dying — drops are not silent: a once-only WARN is logged on the first drop, the mock_server_dropped_log_events Prometheus counter increments under the label reason="in_flight_bytes" (drops because the logging thread fell behind are labelled reason="ring_full"), and the dashboard's log-pressure banner says which kind of drop happened and what to change. A dropped entry is a lost record: a verify with an upper bound (never(), atMost(n), exactly(n), once(), between(a,b)) will fail rather than pass once entries have been dropped — the same fail-closed treatment eviction already has. Resetting the log, or clearing it entirely (a filtered clear(request) does not reset the taint), rearms the warn-once latch and resets the drop taint. Running at WARN still helps against drops: the cap on entries waiting to be logged is heap/7 at WARN /ERROR /OFF and INFO and heap/12 at DEBUG /TRACE, and WARN drains it faster by skipping per-entry formatting. It is not a prerequisite for survival on a 512 MB heap, but it remains the cheapest lever. Note that the default kept history is smaller at WARN (heap/20, against heap/12 at INFO), because WARN is where throughput is high enough for long-lived entries to slow the garbage collector; an unrendered entry costs up to about 1× its counted bytes in the measured runs against up to about 2.3× rendered, so if you raise maxEventLogSizeInBytes at WARN you get more history for the same real heap than at INFO.
maxLoggedBodyBytes has a different, narrower role: it limits how many bytes of each body are stored in the deque, reducing the deque's steady-state footprint and what the dashboard displays. It pairs well with disk capture (persistRecordedRequestsToDisk), which writes the full body before truncation runs and keeps the complete record on disk. Important: setting maxLoggedBodyBytes breaks verification against request or response body content — a verify that matches on body returns a 406 against a truncated entry where the original body would return a 202. Verification by path, method, and header is not affected. The truncation is visible in the retrieved entry and in the dashboard via the x-mockserver-body-truncated: <originalLength> header on the stored copy. It does not prevent ring-backlog drops: maxLoggedBodyBytes truncation runs in the consumer, downstream of the ring, so the body is already in the ring at full size before truncation can run. Use it to manage the deque’s body footprint when disk capture holds the full record, not as a substitute for the byte budget.
Monitoring Memory Usage
To diagnose memory issues, enable CSV memory tracking:
docker run -d --rm -p 1080:1080 \
-e MOCKSERVER_OUTPUT_MEMORY_USAGE_CSV=true \
-e MOCKSERVER_MEMORY_USAGE_CSV_DIRECTORY=/config \
-v $(pwd):/config \
mockserver/mockserver
This creates a memoryUsage_<date>.csv file that records heap usage, log entry count, and expectation count over time. If you see heap usage consistently near the maximum, increase -Xmx or reduce maxLogEntries.
Ongoing Optimisation Work
We are actively investigating further options to reduce the latency tail under high request rates, including changes to garbage collector strategy and JVM runtime configuration. No specific outcomes or timelines are available yet. This section will be updated when measurements confirm an improvement that users should act on.
Proxying LLM / Large-Body Traffic Without OOM
TL;DR: enable disk capture so every exchange is durable, and set a byte budget so the in-memory log stays bounded. Memory never grows unbounded; disk holds the complete session history.
When MockServer proxies LLM traffic — tool schemas, growing conversation context, SSE chunk accumulation — individual request/response bodies can be hundreds of kilobytes each. The in-memory event log is normally bounded only by entry count (maxLogEntries), not by body size, so even a few thousand LLM exchanges can exhaust the heap.
Two complementary settings address this:
| Setting | What it does | When to use |
|---|---|---|
| maxEventLogSizeInBytes | Caps the total request+response body bytes held in memory. Once exceeded, the oldest entries are evicted (oldest-first), in addition to the entry-count bound. | Always, when proxying traffic with large bodies. Set it well under the JVM heap — for a 2 GB heap, 256 MB (268435456) is a good starting point. |
| persistRecordedRequestsToDisk | Appends every proxied exchange — full request and response — as a single NDJSON line to a file, written to disk in small batches as soon as MockServer catches up with its log. The file is the durable record: entries evicted from the in-memory window are never lost from disk (a hard kill can lose only the last few KB of recent lines). | Always paired with maxEventLogSizeInBytes. The disk write happens before any in-memory truncation, so the archive is always full-fidelity. |
Recommended combo for LLM / large-body capture:
java -Xmx2g \
-Dmockserver.maxLogEntries=5000 \
-Dmockserver.maxEventLogSizeInBytes=268435456 \
-Dmockserver.persistRecordedRequestsToDisk=true \
-Dmockserver.persistedRecordedRequestsPath=recordedRequests.ndjson \
-jar mockserver-netty.jar -serverPort 1080
With this configuration:
- Every proxied exchange is written to recordedRequests.ndjson in full as it completes
- The in-memory log retains at most the most recent 256 MB of body bytes (plus up to 5,000 entries by count) — the dashboard shows this recent window
- Older entries evicted from memory are still on disk — nothing is lost
The mockserver-ui/scripts/launch-with-llm-capture.sh script uses exactly this combination by default (2 GB heap, 256 MB byte budget, NDJSON disk capture). Run it with a tool name to start MockServer as an HTTPS proxy capturing that tool's LLM traffic:
./mockserver-ui/scripts/launch-with-llm-capture.sh opencode
Optional secondary valve — maxLoggedBodyBytes: when set to a positive value, bodies are truncated in memory beyond that many bytes (the in-memory copy gets a x-mockserver-body-truncated: <originalLength> header). The disk write is not affected, so the NDJSON archive always holds the full body. Use this only when you want the dashboard to show abbreviated bodies — leave it at 0 (the default) when the byte-budget eviction (maxEventLogSizeInBytes) is sufficient. Be aware that enabling this setting breaks verification against request or response body content — a verify that matches on body will fail against a truncated entry (returning 406 where the full body would return 202). Verification by path, method, and header is not affected.
Security — data at rest: the recordedRequests.ndjson archive contains the full recorded request and response bodies on disk, which for proxied LLM and API traffic can include credentials (Authorization headers, API keys) and other sensitive data. The archive honours redactSecretsInLog — enable it to mask known secret headers and configured body fields in the file just as they are masked in the dashboard. Treat the file as sensitive regardless: store it on a protected volume and delete it when you are done. The capture launcher truncates it at the start of each session (unless you pass --keep-log).
Scalability Configuration:
When enabled (the default), MockServer uses the native Linux epoll transport for higher throughput and lower latency. This is also required for transparent-proxy SO_ORIGINAL_DST resolution, which needs epoll socket channels to read the original destination address from iptables REDIRECT rules. The official Docker images cannot yet use that lookup even on epoll, so there it falls back to the Linux connection-tracking table.
It also applies to the HTTP/3 port (http3Port): with epoll, MockServer reads and sends UDP packets in batches, which keeps large HTTP/3 downloads from stalling on a Linux host with a small UDP receive buffer. See HTTP/3.
On non-Linux platforms (macOS, Windows) this setting has no effect — MockServer transparently falls back to the Java NIO transport regardless. Set to false to force the NIO transport on all platforms, including Linux.
At start-up MockServer logs which transport it chose, for example using native epoll transport or using NIO transport (native epoll transport unavailable: ...). Check this line if you rely on epoll. Docker images published before this release ran NIO even on Linux, because their bundled epoll library could not be loaded.
This property is read at start-up only.
Type: boolean Default: true
Java Code:
ConfigurationProperties.useNativeTransport(boolean enable)
System Property:
-Dmockserver.useNativeTransport=...
Environment Variable:
MOCKSERVER_USE_NATIVE_TRANSPORT=...
Property File:
mockserver.useNativeTransport=...
Example:
-Dmockserver.useNativeTransport=true
How many connections the kernel may hold after completing their TCP handshake, while they wait for MockServer to accept them.
This matters when many clients connect at once — a load test ramping up, a connection pool refilling, or a fleet of containers starting together. Steady traffic over existing keep-alive connections does not touch the accept queue at all.
What a full queue looks like. The kernel does not refuse the connection — it silently drops the handshake packet, and the client retransmits after its initial timeout, typically one second on Linux. So the symptom is a median latency that jumps to around a second with no errors at all, which reads like a slow server rather than a connection limit. If you see that shape, raise this before looking anywhere else.
The OS caps this value. Your setting is reduced to the operating system's limit, so raise both together:
- Linux:
sysctl -w net.core.somaxconn=16384(commonly 4096 by default) - Docker:
docker run --sysctl net.core.somaxconn=16384 ...— containers get their own value, so setting it on the host is not enough - macOS:
sysctl -w kern.ipc.somaxconn=16384
Type: int Default: 1024
Raise this deliberately, not routinely. A deeper queue lets the kernel admit connections your server may then be unable to serve, so the default acts as backpressure: the failure mode changes from slow to dead. Raise it when you have measured that connections are being dropped at the queue, and raise somaxconn with it.
Java Code:
ConfigurationProperties.soBacklog(int backlog)
System Property:
-Dmockserver.soBacklog=...
Environment Variable:
MOCKSERVER_SO_BACKLOG=...
Property File:
mockserver.soBacklog=...
Example:
-Dmockserver.soBacklog=16384
How long, in milliseconds, a client connection may sit completely idle before MockServer closes it. Every open connection costs memory even when it carries no traffic (roughly 4 KB of kernel socket memory plus MockServer's own per-connection state), so clients that open keep-alive connections and never close them can slowly push a container towards its memory limit. This timeout releases them.
Only genuinely quiet connections are closed: nothing read or written for the whole timeout and nothing in progress. A connection is never closed by this timeout while it is:
- waiting for a response — a delayed response, a slow forward, or a request paused at a breakpoint
- streaming a response — Server-Sent Events, chunked or gRPC streaming, however long the gaps between messages
- carrying an open HTTP/2 stream
- a WebSocket or a raw (non-HTTP) proxied connection
A CONNECT or SOCKS proxy tunnel is treated like any other connection: it is closed, together with MockServer's own internal connection for it, once nothing has passed through it for the whole timeout and no request sent through it is waiting for its response. A tunnel with a request still being uploaded, a delayed or streamed response, or an open HTTP/2 stream is never closed by this timeout. Clients that pool proxy tunnels reconnect as they do for any idle connection closed by a server.
Exception: HTTP/1.1 requests pipelined behind a stuck response. With http1PipeliningInOrder on (the default), a request pipelined behind one MockServer is still answering is held, not counted as waiting for a response on its own account. If the response it is held behind makes no progress at all — no bytes written, no stream event — for this same timeout, MockServer closes the connection anyway, so a lost response does not hold pipelined requests open forever. Set the timeout to 0 and this exception stops closing connections too, but MockServer still logs one warning per connection after 5 minutes with no progress regardless of the timeout you set, so a stuck connection stays visible. A long deliberate delay (for example a breakpoint, or a response delayed longer than this timeout) with another request pipelined behind it is cut by this exception, and so is a streamed response whose gap between events is longer than this timeout, even though a stream on its own is never closed by this timeout (above); raise the timeout if you rely on delays or streaming gaps that long together with pipelining.
An error() action that keeps the connection open is not waiting for anything once it has sent its bytes, or if it sends none, so that connection counts as idle and is closed once the timeout passes. A CONNECT or SOCKS proxy tunnel that carried such a request is closed on the same terms.
The same goes for a response whose status code is 1xx (other than 101), such as 102 or 103: it is the whole of what the expectation sends, so nothing more is coming. Over HTTP/1.1 the connection stays open and is closed once the timeout passes. Over HTTP/2 MockServer sends the 1xx and then resets that request's stream (RST_STREAM with NO_ERROR), so the client's request fails at once instead of waiting for a final response, and the connection is closed once it has been idle for the timeout. Over HTTP/3 MockServer sends the 1xx (101 included, as HTTP/3 has no upgrade) without any body or trailers the expectation has, and then resets the stream with H3_NO_ERROR; the client may not see the 1xx before the reset. Over HTTP/2 and HTTP/3, MockServer logs one warning the first time each such expectation is used.
Mainstream HTTP clients (Java, OkHttp, Apache, Python, Node, Go) notice a server closing an idle pooled connection and open a new one, so the 5 minute default should go unnoticed by normal test suites. In rare cases a request sent at the exact moment a connection is closed may fail and need a retry, as with any server that closes idle keep-alive connections. Set it to 0 to never close idle connections, which was the behaviour before this setting existed. The value applies to connections accepted after it is changed.
Type: long Default: 300000 (5 minutes)
Java Code:
ConfigurationProperties.inboundConnectionIdleTimeoutMillis(long millis)
System Property:
-Dmockserver.inboundConnectionIdleTimeoutMillis=...
Environment Variable:
MOCKSERVER_INBOUND_CONNECTION_IDLE_TIMEOUT_MILLIS=...
Property File:
mockserver.inboundConnectionIdleTimeoutMillis=...
Example:
-Dmockserver.inboundConnectionIdleTimeoutMillis=60000
The most client connections MockServer will hold open at once. When the limit is reached, a new connection is closed immediately (the client sees a connection reset straight away rather than hanging), a warning is logged at most every 10 seconds, and mock_server_inbound_connections_rejected_total is incremented. As soon as a connection closes, new ones are accepted again.
Use it to put a hard ceiling on connection memory in a memory-limited container, for example when many load-test clients each hold a pool of keep-alive connections. Watch mock_server_inbound_connections_open to choose a value. Each CONNECT or SOCKS proxy tunnel uses two connections (the client's plus MockServer's own internal connection to itself); if the second is refused the client receives a 502.
HTTP/3 (QUIC) connections are not counted. MockServer's own start-up warm-up request (see startupWarmup) holds one connection for a few seconds after start, so allow for it with a very small limit. The value is read for each new connection, so it can be changed while MockServer is running.
Type: int Default: 0 (no limit)
Java Code:
ConfigurationProperties.maxInboundConnections(int maxConnections)
System Property:
-Dmockserver.maxInboundConnections=...
Environment Variable:
MOCKSERVER_MAX_INBOUND_CONNECTIONS=...
Property File:
mockserver.maxInboundConnections=...
Example:
-Dmockserver.maxInboundConnections=5000
How long, in milliseconds, a response may wait for a client that has stopped reading it before MockServer gives up on it. A client that stops reading (a hung test, a paused debugger, a consumer that stops taking a stream) would otherwise keep what is waiting for it for as long as its connection stays open: an aggregated response up to its full size, and for a streamed response up to maxResponseBodySize plus an open connection to the upstream it is forwarded from.
When bytes of a response have been waiting for the whole timeout and the client has taken none of them, the response ends incomplete, is logged as a warning and is counted by the mock_server_response_write_stalls_total metric:
- HTTP/1.1: the connection is closed (for a CONNECT or SOCKS tunnel, the tunnel is closed)
- HTTP/2 (including HTTP/2 inside a CONNECT or SOCKS tunnel) and HTTP/3: only that stream is reset; other streams on the connection carry on (an HTTP/2 client that stops reading the connection altogether has the connection closed). An HTTP/2 stream that was waiting only for flow-control window held by the stalled stream is not reset before it, and gets another timeout period to receive that window once the stalled stream is reset. An HTTP/2 client counts as reading a stream only while the stream's data is actually being sent to it: flow-control frames that let no data be sent (a WINDOW_UPDATE while the connection's window is used up, or a changed initial window size) do not keep a stalled stream open. An HTTP/3 stream that has been sent little or nothing because stalled streams hold the connection's flow-control credit also gets another timeout period once such a stream is reset
- a streamed (forwarded) response: its upstream connection is closed too
A client that keeps taking some of the response at least once per timeout period is not affected, and neither is a client waiting for a delayed or slow response, because nothing is waiting for it to read. The same applies to a WebSocket whose client stops reading the messages sent to it. The operating system sends data to a slow reader in bursts, sometimes seconds apart, so keep the timeout well above a few seconds; the 1 minute default leaves plenty of room. Set it to 0 to never cut off a stalled client, which was the behaviour before this setting existed. The value applies to connections accepted after it is changed.
Type: long Default: 60000 (1 minute)
Java Code:
ConfigurationProperties.responseWriteStallTimeoutMillis(long millis)
System Property:
-Dmockserver.responseWriteStallTimeoutMillis=...
Environment Variable:
MOCKSERVER_RESPONSE_WRITE_STALL_TIMEOUT_MILLIS=...
Property File:
mockserver.responseWriteStallTimeoutMillis=...
Example:
-Dmockserver.responseWriteStallTimeoutMillis=120000
Number of threads for main event loop
These threads are used for fast non-blocking activities such as:
- reading and de-serialise all requests
- serialising and writing control plane responses
- adding, updating or removing expectations
- verifying requests or request sequences
- retrieving logs
Expectation actions are handled in a separate thread pool to ensure slow object or class callbacks and response / forward delays do not impact the main event loop.
Type: int Default: 5
Java Code:
ConfigurationProperties.nioEventLoopThreadCount(int count)
System Property:
-Dmockserver.nioEventLoopThreadCount=...
Environment Variable:
MOCKSERVER_NIO_EVENT_LOOP_THREAD_COUNT=...
Property File:
mockserver.nioEventLoopThreadCount=...
Example:
-Dmockserver.nioEventLoopThreadCount="5"
Number of threads for the action handler thread pool
These threads are used for handling actions such as:
- serialising and writing expectation or proxied responses
- handling response delays in a non-blocking way (i.e. using a scheduler)
- executing class callbacks
- handling method / closure callbacks (using web sockets)
Type: int Default: maximum of 5 or available processors count
Java Code:
ConfigurationProperties.actionHandlerThreadCount(int count)
System Property:
-Dmockserver.actionHandlerThreadCount=...
Environment Variable:
MOCKSERVER_ACTION_HANDLER_THREAD_COUNT=...
Property File:
mockserver.actionHandlerThreadCount=...
Example:
-Dmockserver.actionHandlerThreadCount="5"
Maximum number of matched requests that can be waiting at the same time for a configured delay before their response is sent. This covers a response or forward delay, globalResponseDelayMillis and chaos latency.
Each waiting request is held in memory until its delay ends, so a high request rate against a slow expectation (for example 1,000 requests per second with a 10 second delay keeps 10,000 requests waiting) can use a lot of heap. When the limit is reached, further requests are answered straight away with 503 Service Unavailable and a Retry-After: 1 header instead of being held, so the server stays healthy.
A request answered this way has still matched its expectation: it counts towards the expectation's times (so a once() expectation is used up), may have used a rate-limit allowance, and has no response entry in the request log. A forwarded response that is waiting only for chaos latency is sent immediately without the latency instead of being refused, because the upstream has already been called.
Delayed side actions (after-actions, secondary actions and step side effects) have a separate allowance of the same size. When it is full further ones are skipped and logged, so they never cause a 503.
Delayed replies of a two-way (bidirectional) WebSocket mock have a third allowance of the same size, counted in reply messages. A reply to one incoming message is always sent completely or not at all: when the allowance is full, the WebSocket is closed with status 1013 (Try Again Later) and none of that reply is sent. Before that point, a single connection with more than 128 delayed replies still waiting simply stops being read until they drain, which slows a fast client down instead of failing it.
Rejections are reported in a WARN log line at most every 10 seconds and, when metrics are enabled (metricsEnabled), counted by the mock_server_overload_rejections metric (with reason delayed_responses, delay_skipped, side_actions or websocket_replies); the mock_server_pending_delayed_tasks metric shows how many are currently waiting.
Raise this if you deliberately run many slow requests at once and have the heap for them; set it to 0 to restore the previous unlimited behaviour (not recommended).
Type: int Default: maximum heap divided by 64 KB (an estimate of the memory each waiting request holds), up to 100,000 (about 8,000 for a 512 MB heap; 1,000 if the JVM reports no heap limit)
Java Code:
ConfigurationProperties.maxPendingDelayedResponses(int maxPendingDelayedResponses)
System Property:
-Dmockserver.maxPendingDelayedResponses=...
Environment Variable:
MOCKSERVER_MAX_PENDING_DELAYED_RESPONSES=...
Property File:
mockserver.maxPendingDelayedResponses=...
Example:
-Dmockserver.maxPendingDelayedResponses="20000"
Maximum number of response or forward templates (Velocity, Mustache or JavaScript) that can be queued waiting to be rendered.
Templates are rendered on a dedicated pool of threads (as many as actionHandlerThreadCount). If requests arrive faster than templates can be rendered they queue, and each queued request is held in memory. When the limit is reached, further templated requests are answered straight away with 503 Service Unavailable and a Retry-After: 1 header. As with delayed responses, the request still counts as a match for its expectation and has no response entry in the request log. Rejections are reported in a WARN log line at most every 10 seconds and, when metrics are enabled, counted by the mock_server_overload_rejections metric (with reason="template_actions"); the mock_server_template_action_queued_tasks metric shows the current queue length.
If you see these rejections at a load you expect to handle, simplify the template or raise actionHandlerThreadCount before raising this limit; set it to 0 to restore the previous unlimited behaviour (not recommended).
Type: int Default: maximum heap divided by 64 KB, up to 100,000 (about 8,000 for a 512 MB heap; 1,000 if the JVM reports no heap limit)
Java Code:
ConfigurationProperties.maxQueuedTemplateActions(int maxQueuedTemplateActions)
System Property:
-Dmockserver.maxQueuedTemplateActions=...
Environment Variable:
MOCKSERVER_MAX_QUEUED_TEMPLATE_ACTIONS=...
Property File:
mockserver.maxQueuedTemplateActions=...
Example:
-Dmockserver.maxQueuedTemplateActions="20000"
Number of threads for client event loop when calling downstream
These threads are used for fast non-blocking activities such as, reading and de-serialise all requests and responses
Type: int Default: 5
Java Code:
ConfigurationProperties.clientNioEventLoopThreadCount(int count)
System Property:
-Dmockserver.clientNioEventLoopThreadCount=...
Environment Variable:
MOCKSERVER_CLIENT_NIO_EVENT_LOOP_THREAD_COUNT=...
Property File:
mockserver.clientNioEventLoopThreadCount=...
Example:
-Dmockserver.clientNioEventLoopThreadCount="5"
Number of threads for each expectation with a method / closure callback (i.e. web socket client) in the org.mockserver.client.MockServerClient
This setting only effects the Java client and how requests each method / closure callbacks it can handle, the default is 5 which should be suitable except in extreme cases.
Type: int Default: 5
Java Code:
ConfigurationProperties.webSocketClientEventLoopThreadCount(int count)
System Property:
-Dmockserver.webSocketClientEventLoopThreadCount=...
Environment Variable:
MOCKSERVER_WEB_SOCKET_CLIENT_EVENT_LOOP_THREAD_COUNT=...
Property File:
mockserver.webSocketClientEventLoopThreadCount=...
Example:
-Dmockserver.webSocketClientEventLoopThreadCount="5"
Maximum time allowed in milliseconds for any future to wait — for example when waiting for a response over a method / closure callback (web socket), and as the outer cap on every forward/proxy wait (including maxSocketTimeoutInMillis and socketConnectionTimeoutInMillis): whichever of this value or the more specific timeout is reached first ends the wait. Raise it if you also raise those timeouts past 90 seconds, otherwise the forward still times out at this value regardless.
Type: long Default: 90000
Java Code:
ConfigurationProperties.maxFutureTimeout(long milliseconds)
System Property:
-Dmockserver.maxFutureTimeout=...
Environment Variable:
MOCKSERVER_MAX_FUTURE_TIMEOUT=...
Property File:
mockserver.maxFutureTimeout=...
Example:
-Dmockserver.maxFutureTimeout="90000"
Also accepted under the unit-bearing name maxFutureTimeoutInMillis / MOCKSERVER_MAX_FUTURE_TIMEOUT_IN_MILLIS, which matches the Java API (Configuration.maxFutureTimeoutInMillis()) and the value reported in the configuration JSON. The two names are the same setting; set whichever you prefer.
If true (the default) request matchers will fail on the first non-matching field, if false request matchers will compare all fields.
Set to false when debugging matching issues to see all mismatching fields in a single log entry. See Troubleshooting Matching for a step-by-step guide.
Type: boolean Default: true
Java Code:
ConfigurationProperties.matchersFailFast(boolean enable)
System Property:
-Dmockserver.matchersFailFast=...
Environment Variable:
MOCKSERVER_MATCHERS_FAIL_FAST=...
Property File:
mockserver.matchersFailFast=...
Example:
-Dmockserver.matchersFailFast="false"
Internal performance tuning knob, not part of the standard configuration set. Below this many registered expectations, request matching always runs the plain linear scan; at or above it, MockServer engages an internal candidate index that narrows the scan for literal method+path expectation sets. The default of 64 keeps small/common expectation sets on the unchanged linear scan while large ones get the speed-up. You should not normally need to change this; it exists for benchmarking and for tuning very large expectation sets.
Values below 2 are raised to 2 (a store of 0 or 1 expectations can never benefit from the index). This is a JVM system property only — it has no environment-variable or properties-file form, and setting it in either of those is reported as ignored, with the correct -D form suggested. It is also read once per request-matcher construction, so changing it on a running server has no effect.
Type: int Default: 64 (JVM system property only; no Java API)
System Property:
-Dmockserver.candidateIndexThreshold=...
Example:
-Dmockserver.candidateIndexThreshold="100"
Maximum request body size in bytes that conversation-aware LLM matchers will parse. LLM conversation matchers (whenLatestMessageContains, whenContainsToolResultFor, etc.) parse the inbound request body as JSON to extract the message history. For deep conversation histories or large tool results, this parse step is proportional to body size.
Requests whose body exceeds this cap skip conversation-aware matching and are treated as a no-match for conversation predicates (the scenario state machine is unaffected). Increase this value only when your LLM conversations regularly include very large tool results or long message histories. Reduce it in memory-constrained environments to bound the maximum allocation per matching attempt. Values outside the supported range are clamped to the nearest limit whenever the setting is read, and an INFO log entry names the clamped value.
Type: int Default: 1048576 (1 MiB) Range: 16384 (16 KiB) — 67108864 (64 MiB)
Java Code:
ConfigurationProperties.maxLlmConversationBodySize(int size)
Configuration.maxLlmConversationBodySize(Integer size)
System Property:
-Dmockserver.maxLlmConversationBodySize=...
Environment Variable:
MOCKSERVER_MAX_LLM_CONVERSATION_BODY_SIZE=...
Property File:
mockserver.maxLlmConversationBodySize=...
Example:
-Dmockserver.maxLlmConversationBodySize="4194304"
Allowlist of custom load-scenario label names (comma-separated) that are added as extra, fixed Prometheus labels on the mock_server_load_* metrics. Prometheus requires a fixed label-name set at registration time, so only the keys named here are exposed as Prometheus labels on those metrics — any custom label a scenario or step sets via withLabels(...) that is not listed here is silently dropped from Prometheus output, though every custom label is always attached as an OpenTelemetry attribute regardless of this setting.
When empty (the default), the Prometheus load metrics carry only the fixed structured labels (scenario, run_id, step, route, method, status_class). Set it once at startup to the label keys your load scenarios use (e.g. env,region,team) so they appear as Prometheus labels; it cannot be changed per-scenario because Prometheus label sets are fixed per metric. See Observability & Metrics for the full load-testing metrics reference.
Type: string (comma-separated) Default: "" (empty — no extra Prometheus labels)
Java Code:
ConfigurationProperties.loadGenerationMetricLabels(String labels)
System Property:
-Dmockserver.loadGenerationMetricLabels=...
Environment Variable:
MOCKSERVER_LOAD_GENERATION_METRIC_LABELS=...
Property File:
mockserver.loadGenerationMetricLabels=...
Example:
-Dmockserver.loadGenerationMetricLabels="env,region,team"
Historical benchmarks (Apache Bench & Locust, Java 13)
The results below are from an earlier benchmarking exercise and are kept for reference. They are historical and indicative — measured on an older Java 13 JVM on specific hardware using a closed-model client-count load (which is subject to coordinated omission), so they are not directly comparable with the open-model figures above. They showed an average of 1.58 ms and a p99 of 4 ms for 150 parallel clients sending 95,228 requests per second.
The scenario was four basic expectations (method, path and headers), with a GET request matching the third expectation. MockServer was run on a Java 13 JVM with:
java -Xmx500m -Dmockserver.logLevel=WARN -Dmockserver.disableLogging=true -jar ~/.m2/repository/org/mock-server/mockserver-netty/9.2.0/mockserver-netty-9.2.0-no-dependencies.jar -serverPort 1080
Note: the benchmark used -Dmockserver.disableLogging=true and -Dmockserver.logLevel=WARN to keep console output off the measurement. Neither setting stops requests being recorded into the in-memory request log — that is bounded by maxLogEntries and maxEventLogSizeInBytes, not by the logging switches.
Apache Bench results
Apache Benchmark was executed as:
ab -k -n 10000000 -c <parallel clients> http://127.0.0.1:1080/simple
| parallel clients | 50% | 66% | 75% | 80% | 90% | 95% | 98% | 99% | requests/s | mean |
| 10 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 77,122 | 0.13 |
| 50 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 85,765 | 0.58 |
| 100 | 1 | 1 | 1 | 1 | 1 | 2 | 2 | 3 | 92,846 | 1.08 |
| 150 | 1 | 2 | 2 | 2 | 2 | 2 | 3 | 4 | 95,228 | 1.58 |
| 250 | 3 | 3 | 3 | 3 | 4 | 5 | 7 | 8 | 86,470 | 2.89 |
| 500 | 6 | 6 | 6 | 6 | 7 | 7 | 8 | 9 | 83,209 | 6.01 |
| 750 | 9 | 9 | 10 | 10 | 11 | 11 | 12 | 15 | 75,554 | 9.93 |
| 1000 | 11 | 12 | 13 | 13 | 14 | 16 | 17 | 21 | 75,423 | 13.26 |
| 2000 | 24 | 24 | 25 | 26 | 27 | 29 | 31 | 35 | 82,191 | 24.33 |
| 3000 | 37 | 39 | 40 | 40 | 43 | 46 | 51 | 58 | 78,171 | 38.38 |
| 4000 | 52 | 55 | 57 | 59 | 64 | 70 | 82 | 91 | 73,552 | 54.38 |
| 5000 | 65 | 67 | 70 | 71 | 75 | 79 | 90 | 102 | 74,065 | 67.51 |
| 6000 | 80 | 84 | 88 | 90 | 97 | 104 | 122 | 137 | 70,432 | 85.19 |
Locust results
Locust was executed as:
locust --loglevel=WARNING --headless --only-summary -u <parallel clients> -r 100 -t 180 --host=http://127.0.0.1:1080
| parallel clients | 50% | 66% | 75% | 80% | 90% | 95% | 98% | 99% | 99.90% | 99.99% | requests/s | mean |
| 10 | 0 | 0 | 1 | 1 | 1 | 1 | 1 | 2 | 5 | 5 | 11 | 0 |
| 50 | 0 | 0 | 1 | 1 | 1 | 1 | 1 | 2 | 3 | 5 | 50 | 0 |
| 100 | 0 | 1 | 1 | 1 | 1 | 2 | 2 | 3 | 4 | 8 | 100 | 0 |
| 150 | 1 | 1 | 1 | 1 | 2 | 3 | 3 | 4 | 5 | 6 | 149 | 0 |
| 250 | 2 | 3 | 3 | 4 | 5 | 6 | 7 | 8 | 15 | 46 | 245 | 2 |
| 500 | 2 | 2 | 3 | 3 | 4 | 5 | 6 | 7 | 9 | 46 | 479 | 2 |
| 750 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 14 | 29 | 34 | 699 | 3 |
| 1000 | 3 | 4 | 6 | 6 | 8 | 10 | 13 | 16 | 36 | 52 | 909 | 3 |
| 2000 | 4 | 7 | 10 | 12 | 22 | 34 | 49 | 59 | 87 | 110 | 1626.14 | 8 |
| 3000 | 51 | 78 | 99 | 110 | 160 | 180 | 220 | 240 | 290 | 310 | 2629.92 | 54 |