The standard mockserver/mockserver image reaches ready in roughly 0.57 s — about a third faster than MockServer 7.4.0 — thanks to a class-data-sharing archive baked in at build time. For the lowest possible container start, the -aot image variant uses a JDK 25 Leyden AOT cache and reaches ready in roughly 0.35 s. A built-in warmup step means the first real request you send is answered in ~5–10 ms rather than the hundreds of milliseconds a cold JVM would otherwise take. For test suites, the single biggest speed lever remains one container per suite with reset() between tests — that brings per-test cost to milliseconds regardless of image choice.

Image Time to ready (typical) Notes
mockserver/mockserver (standard) ~0.57 s Default. Class-data-sharing archive baked at build time (JDK 17). Identical runtime behaviour to a plain JVM start; archive fallback if unusable.
mockserver/mockserver:<version>-aot ~0.35 s JDK 25 Leyden AOT cache. CPU-arch specific. JDK TLS provider (see below).
In-process (JVM test, no container) Tens of ms JVM already warm. No Docker overhead. See Fast Tests.

Figures measured on arm64 Mac, medians of 5 cold starts, container-launch to first successful PUT /mockserver/status response. Absolute numbers are machine-relative; they shift on different hardware and JDK builds.

 

Standard image — class-data sharing

The standard mockserver/mockserver image includes a class-data-sharing (CDS) archive produced at image-build time. CDS lets the JVM map pre-parsed class metadata directly from a shared file, skipping the work of reading and verifying every class from the jar on each cold start. The archive was trained against the real MockServer startup path, so the classes that matter most arrive cheapest.

If the archive is ever unusable — for example because the image was rebuilt against a different JDK patch — the JVM logs a warning and starts normally. There is no risk of incorrect behaviour from the archive.

The image is built on a jlink-trimmed JDK 17 runtime on a distroless base, so the archive size (~33 MB) is offset by the smaller runtime: the complete image is 411 MB versus 422 MB for the pre-archive 7.4.0 image.

 

-aot image — JDK 25 Leyden AOT cache

The -aot image tags (mockserver/mockserver:latest-aot, mockserver/mockserver:<version>-aot) go further: they bake a Project Leyden AOT class-metadata cache (JEPs 483, 514, 515) into the image. At build time the image starts MockServer as a training run, records which classes were loaded and how they were used, and writes the result into the image. At runtime the JVM reads the cache rather than loading and resolving classes from scratch.

Because it is the real HotSpot JVM, the -aot variant has 100% feature parity with the standard image — no closed-world limitations, no missing features at runtime. The same expectations, templates, TLS, and class callbacks all work. Unlike a native binary, there is no build-time metadata to maintain.

Caveats to be aware of:

  • The AOT cache is CPU-architecture and JDK-build specific. A multi-arch build bakes a separate cache per platform; the cache for an amd64 image cannot run on arm64 and vice versa.
  • This variant uses the JDK TLS provider rather than netty-tcnative. TLS connections work correctly; TLS handshake throughput under sustained high load may be marginally lower.
  • If the cache is incompatible (wrong architecture or JDK build), the JVM logs a warning and starts normally — the same graceful fallback as the standard image archive.

Selecting the -aot image in Testcontainers:

// aotImage() returns the default image with -aot appended to the tag,
// staying in lockstep with the client library version.
try (MockServerContainer mockServer = new MockServerContainer(MockServerContainer.aotImage())) {
    mockServer.start();
    // use mockServer.getClient() as normal
}

// Or pin an explicit -aot tag:
try (MockServerContainer mockServer = new MockServerContainer(
        DockerImageName.parse("mockserver/mockserver:7.4.0-aot"))) {
    mockServer.start();
}
 

First-request warmup

Even after the port binds and the container reports healthy, a cold JVM takes an extra 200–350 ms to answer the very first request. That penalty comes from a burst of ~217 classes that the JVM loads the first time it runs the full request-handling path — Netty HTTP encoders, Jackson serializers, response writers. Every subsequent request avoids that cost entirely.

MockServer eliminates this penalty automatically. Immediately after the port binds, a background thread issues one internal PUT /mockserver/status request, which drives the full request-handling path and loads that class burst before any real caller arrives. The result: the first request you send from outside lands in ~5–10 ms instead of 200–350 ms.

The warmup runs as a daemon thread so it never delays JVM shutdown. If it fails for any reason (network not yet available, timeout) it logs the failure at TRACE level and moves on — the server continues normally. The warmup request goes through the same pre-authentication path as health checks and Kubernetes readiness probes, so it works even when control-plane authentication is enabled.

Configuration. The warmup is on by default. To disable it — for example when benchmarking cold-start behaviour — set:

  • Java system property: -Dmockserver.startupWarmup=false
  • Environment variable: MOCKSERVER_STARTUP_WARMUP=false

Full property reference: Startup Warmup — Configuration Properties.

 

Lazy subsystem initialisation

Two subsystems that are only needed in some deployments now initialise on demand rather than at startup:

  • TLS crypto. The BouncyCastle security provider (~460 classes) loads only when MockServer first generates or uses a TLS certificate — typically the first HTTPS connection. A server used purely for HTTP mocking never pays that cost.
  • Proxy event-loop threads. The event-loop group used for forwarding requests to upstream servers is created only when the first proxied request arrives. A mock-only server never allocates those threads or their associated memory. If you enable proactivelyInitialiseTLS=true, TLS is still set up at startup — that property is designed for deployments that need TLS ready immediately, and lazy initialisation does not override it.
 

Fast test suites

The largest speed gain available in a test suite is architectural, not runtime: start one container for the whole suite and call client.reset() between tests. reset() clears all expectations and recorded requests in milliseconds, giving the same isolation as a fresh container without paying the startup cost per test. For JVM tests that need no network isolation, running MockServer in-process eliminates container overhead altogether.

See Fast Tests: Startup Time, Memory and Reuse for complete examples including suite-scoped containers, withReuse(true) for local development, memory caps, and when to choose in-process versus containerised.

Wait strategy. The default Testcontainers wait strategy (HTTP GET on /mockserver/status) is fine as-is. Because the built-in warmup runs before the first external request arrives, by the time the wait strategy polling period elapses the server has already warmed itself and the first real request you send will be fast.

 

How these figures are measured

All numbers on this page come from a measurement harness in scripts/perf/. The methodology:

  • Metric: wall-clock time from docker run (or java -jar) until the first successful response to PUT /mockserver/status — this is the time a caller or Testcontainers wait strategy would observe.
  • Sample: medians of 5 independent cold starts. The first run after building an artifact is often slower (OS page cache cold), so runs are discarded if they are clear outliers.
  • Machine-relative: absolute times depend on the host machine, JDK build, and Docker daemon. Only compare figures produced on the same machine in the same session.
  • Tight poll races warmup: the harness polls every 2 ms, which can finish before the built-in warmup completes. The first-request figures on this page use a 600 ms post-bind poll interval, which is closer to a real Testcontainers wait-strategy interval. At that interval, the warmup has already run and the first response is ~5–10 ms.
 

Where the Java ecosystem is heading

Several JVM startup technologies are maturing in parallel. The -aot image already uses JDK 25 Leyden (JEPs 483, 514, 515). Here is where the broader landscape sits:

Technology JDK status What it does
Project Leyden — class-metadata cache (JEPs 483, 514, 515) GA in JDK 24/25 Pre-loads class loading, linking, and AOT profiling. The -aot image uses this today.
Project Leyden — AOT code compilation (draft JEP 8335368) No release target yet Would pre-compile JIT output into the AOT cache, reducing time-to-peak performance as well as startup time.
CRaC (Checkpoint/Restore in Userspace) Azul and Liberica JDKs only — not mainline OpenJDK Snapshots a running JVM and restores from checkpoint in ~50 ms. Requires a non-mainline JDK and the checkpoint contains environment state (credentials, CA keys) that must be regenerated on restore.
GraalVM native-image Community/commercial product, not mainline JDK Compiles to a native binary; no JVM at runtime. MockServer does not offer a native-image distribution because its feature set depends on runtime class loading (class callbacks, expectation initializers, custom matchers) which is incompatible with native-image's closed-world assumption. In evaluation, features not explicitly traced during build-time metadata collection failed silently at runtime without reporting a clear error. The supported lightweight distribution for JVM-less environments is the jlink binary bundle.

See also