Sometimes, bugs are obvious, trivial and punch you right in the face. Sometimes, they are so subtle that you start questioning your sanity.
This is a story about one of the latter, involving a bit of accidental time travel.
Wiremock and Practical Time Stubbing
A common integration testing pattern involves stubbing external REST APIs, and Wiremock is one of the most popular tools for that and my personal go-to choice. It can be easily set up with Testcontainers and JUnit5:
@Testcontainers @ExtendWith(TestcontainersExtension.class) class WireMockExampleTest { @Container static GenericContainer<?> wiremock = new GenericContainer<>("wiremock/wiremock:3.13.1") .withExposedPorts(8080) .withCommand("--port 8080"); @BeforeAll static void setup() { WireMock.configureFor(wiremock.getHost(), wiremock.getMappedPort(8080)); } @Test void example() { WireMock.stubFor(WireMock.get("/hello") .willReturn(WireMock.aResponse() .withStatus(200) .withBody(""" "message": "hello" """))); // ... } }
And now, we can point our code at WireMock and pretend it’s our external service and benefit from determinism!
Naturally, static responses won’t get us far, so soon we’ll need to start parameterizing:
WireMock.stubFor(WireMock.get("/timestamp") .willReturn(WireMock.aResponse() .withStatus(200) .withBody(""" "value": "%s """ .formatted(Instant.now()))));
Right?
The problem with this approach is that Instant is resolved eagerly during the test setup and returned on every call, which is fine as long as we need a mere timestamp placeholder:
var baseUrl = "http://%s:%d".formatted(wiremock.getHost(), wiremock.getMappedPort(8080)); try (var client = HttpClient.newHttpClient()) { System.out.println(client.send(HttpRequest.newBuilder() .uri(URI.create(baseUrl + "/timestamp")) .header("Accept", "application/json") .build(), HttpResponse.BodyHandlers.ofString()).body()); Thread.sleep(1000); System.out.println(client.send(HttpRequest.newBuilder() .uri(URI.create(baseUrl + "/timestamp")) .header("Accept", "application/json") .build(), HttpResponse.BodyHandlers.ofString()).body()); } // "value": "2025-07-08T17:30:13.415723Z // "value": "2025-07-08T17:30:13.415723Z
But what if we start relying on that value?
Let’s take this example, where we resolve timestamps twice and then use it for sorting our messages:
Set<Message> log = new HashSet<>(); log.add(new Message(Instant.now(), "first")); var baseUrl = "http://%s:%d".formatted(wiremock.getHost(), wiremock.getMappedPort(8080)); try (var client = HttpClient.newHttpClient()) { var json = client.send(HttpRequest.newBuilder() .uri(URI.create(baseUrl + "/timestamp")) .header("Accept", "application/json") .build(), HttpResponse.BodyHandlers.ofString()).body(); log.add(new Message(parseKeyAsInstant(json.trim(), "value"), "second")); } log.stream() .sorted(Comparator.comparing(Message::timestamp)) .forEach(System.out::println);
The result is obviously wrong! We got our messages in the wrong order!
Message[timestamp=2025-07-08T17:53:04.410Z, value=second] Message[timestamp=2025-07-08T17:53:04.581948Z, value=first]
This is precisely why it’s a bad idea for distributed systems to rely on wall clock to establish global ordering.
Wiremock supports dynamic templating, which allows timestamps (and many other things) to be resolved exactly when the call happens – this is precisely what we need!
This can be achieved by placing {{now}} in the body! Problem solved… right?
Unfortunately, from time to time messages can still appear out of order:
Message[timestamp=2025-07-08T19:14:48Z, value=second] Message[timestamp=2025-07-08T19:14:48.722149Z, value=first]
If you look closely, that’s because {{now}} defaults to ISO8601-complicant timestamp truncated to seconds. Ironically, this introduces a similar problem because those milliseconds matter here!
In order to increase the precision, we need to instruct Wiremock to use a custom format and make sure to include enough S:
{{now format="yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"}}
Unfortunately, again, the issue remains unsolved! Entries are still out of order from time to time, but at least timestamp includes desired precision:
Message[timestamp=2025-07-08T19:58:08.000519Z, value=second] Message[timestamp=2025-07-08T19:58:08.433615Z, value=first]
But… does it? Have a closer look at both timestamps. Can you see anything suspicious?
Let me help you. Here’s results from a few more runs:
Message[timestamp=2025-07-08T20:00:37.000086Z, value=second] Message[timestamp=2025-07-08T20:00:37.003508Z, value=first] Message[timestamp=2025-07-08T20:01:05.000155Z, value=second] Message[timestamp=2025-07-08T20:01:05.062106Z, value=first] Message[timestamp=2025-07-08T20:01:25.000413Z, value=second] Message[timestamp=2025-07-08T20:01:25.326382Z, value=first]
The milliseconds part of the timestamp of the second message seems to always start with a couple of zeros. Nothing really impossible, but statistically unlikely.
However, look at the microseconds part. This seems to be always higher than milliseconds part of the first timestamp!
The Core Issue
WireMock, via Handlebars templating, relies on Java’s SimpleDateFormat, a legacy class from the pre-java.time era. This formatter doesn’t understand microsecond or nanosecond precision.
So when you specify more than 3 S characters in the format string, it doesn’t round or truncate as you might expect, but it zero-pads the millisecond value instead, leading to subtly wrong timestamps that look ok, but are slightly in the past:
var date = new Date(Instant.parse("2025-07-07T15:23:11.123000Z").toEpochMilli()); var formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"); formatter.setTimeZone(TimeZone.getTimeZone("UTC")); assertThat(formatter.format(date)).isEqualTo("2025-07-07T15:23:11.000123Z");
Such an important feature is not documented well by SimpleDateFormat. All you can find in the documentation is one vague line:
For formatting, the number of pattern letters is the minimum number of digits, and shorter numbers are zero-padded to this amount.
Luckily, it turns out that if we want to make SimpleDateFormat return correct timestamps, all we need to do is to reduce the number of Ss to match the maximum precision to avoid left-padding issues.
This is not the issue with DateTimeFormatter
, which is right-padded:
var instant = Instant.parse("2025-07-07T15:23:11.123000Z"); var date = new Date(instant.toEpochMilli()); var simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); simpleDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); var simpleDateFormatter = simpleDateFormat.format(date); var dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'") .format(instant.atZone(ZoneOffset.UTC)); assertThat(simpleDateFormatter).isEqualTo("2025-07-07T15:23:11.123Z"); assertThat(dateTimeFormatter).isEqualTo("2025-07-07T15:23:11.123000Z");
Epilogue
There’s no practical reason to be using SimpleDateFormat in 2025 other than historical reasons.
That said, there’s a certain joy in debugging this kind of subtle failures but the moment this kind of issue appears in production that joy turns to dread, so to help prevent this kind of headache for others, I submitted two Pull Requests to WireMock that, once merged, should make this less of a problem for the future: