You deploy a Spring Boot service. Most requests are fast. Then one request is mysteriously slower.
The response is still 200 OK, but your APM tool shows something uncomfortable:
- Jackson is trying to create a serializer
- Spring is involved in the instantiation path
- a
DateTimeFormatterdependency cannot be resolved - the issue only appears on the “first” request path, then disappears
This post is based on a real debugging pattern around custom Jackson serializers, ZonedDateTime, and Spring’s SpringHandlerInstantiator.
The interesting part is not just the exception.
The interesting part is why it happens only on a cold path, and why the error may be visible in Dynatrace but not obvious in normal application logs.
The symptom
Imagine you have an API response containing a ZonedDateTime field:
public record ValidationResult(
String decision,
ZonedDateTime evaluatedAt
) {}
And somewhere in your model or generated code, the field is configured with a custom serializer:
@JsonSerialize(using = ZonedDateTimeSerializer.class)
private ZonedDateTime evaluatedAt;
Most requests look fine.
But occasionally, after a restart, deploy, or first access to a specific response path, you see a slower request and an internal exception chain like:
Error creating bean with name '...ZonedDateTimeSerializer':
Unsatisfied dependency expressed through constructor parameter 0:
No qualifying bean of type 'java.time.format.DateTimeFormatter' available
The confusing part:
- the request may still succeed
- the issue may not repeat on the next request
- adding breakpoints makes the behavior easier to observe
- the latency appears only around the first serialization path
- the exception may not be printed in your normal application logs
- Dynatrace or another APM tool may still capture it as an internal exception
That is a classic “cold serializer initialization” smell.
The mental model: Jackson serializers are often built lazily
Jackson does not always eagerly build every serializer when the application starts.
For many types, serializer construction happens when the ObjectMapper first needs to serialize that type in a specific context.
So the first request that returns a response containing a certain field may trigger:
- inspect response type
- discover field annotations
- resolve serializer class
- instantiate serializer
- cache the serializer
- serialize the response
After that, the serializer may be cached, so later requests look normal.
This is why a production issue can look “intermittent” even though the configuration bug is deterministic.
It is deterministic per JVM / per application instance / per serialization path.
Where Spring enters the picture
In a Spring Boot application, Jackson’s ObjectMapper is usually integrated with Spring.
One important integration point is SpringHandlerInstantiator.
Its job is to let Jackson-created handlers — serializers, deserializers, key deserializers, type resolvers, and similar extension points — be instantiated with Spring support.
That is useful because it allows serializers/deserializers to use Spring-managed dependencies.
For example, this can work:
public class ZonedDateTimeSerializer extends JsonSerializer<ZonedDateTime> {
private final DateTimeFormatter formatter;
public ZonedDateTimeSerializer(DateTimeFormatter formatter) {
this.formatter = formatter;
}
@Override
public void serialize(
ZonedDateTime value,
JsonGenerator gen,
SerializerProvider serializers
) throws IOException {
gen.writeString(formatter.format(value));
}
}
But there is a hidden contract:
If your serializer has a constructor dependency, Spring must be able to resolve that dependency when Jackson asks for the serializer.
If Spring sees DateTimeFormatter formatter as a constructor argument and there is no matching bean, instantiation can fail.
Why this can surprise you
A lot of developers think of a Jackson serializer as a simple utility class.
But once Spring participates in constructing it, it behaves more like a Spring-created component:
public ZonedDateTimeSerializer(DateTimeFormatter formatter) {
this.formatter = formatter;
}
That constructor is no longer just “a convenient constructor”.
It is now also a dependency declaration.
If there is no DateTimeFormatter bean, or if there are multiple formatter beans without a qualifier, the serializer may fail to instantiate.
Why the request can still return 200 OK
This depends on the exact setup, so don’t rely on it.
In some cases, serializer construction failure results in a hard response failure.
In other cases, the failure appears as an internal failed attempt, and another serializer path or fallback still successfully serializes the value.
That second behavior is especially dangerous because it creates a misleading production signal:
- customers see success
- logs may look clean
- Dynatrace shows an internal exception
- the request is slower
- the issue disappears after warm-up
A successful response does not mean the serialization path is healthy.
Why the error may not appear in normal logs
One frustrating part of this issue is that you may not find the full exception in application logs, even though Dynatrace shows it.
That can happen for several reasons.
First, the exception can be thrown and caught inside framework code during serializer resolution. Frameworks sometimes try one construction path, catch the failure, and then continue with another path or fallback behavior. If the framework catches the exception and does not log it at ERROR, your normal application logs may never show it.
Second, your logging configuration may not include the relevant package or level. If the internal failure is logged at DEBUG or TRACE under Jackson or Spring internals, production log settings may suppress it.
Third, APM tools such as Dynatrace instrument method calls and exception events at runtime. They can capture thrown exceptions even when those exceptions are later caught and never printed by your application logger.
That explains the strange situation:
Application log: no obvious error
HTTP response: 200 OK
Dynatrace trace: internal exception during serialization
User-visible symptom: slower first request
So if you see this only in Dynatrace, do not dismiss it as a false positive immediately. It may be a real exception that is caught internally, still adding latency and noise to the cold path.
How to reproduce it locally with breakpoints
The easiest way to understand this bug is to reproduce the cold serialization path locally.
The goal is not only to call the endpoint.
The goal is to pause exactly when Jackson asks Spring to create the serializer.
1) Start from a cold application state
Restart the application completely.
If you are running tests, restart the test context or run a focused test class in isolation.
The key is to avoid a warmed ObjectMapper serializer cache.
2) Hit an endpoint that returns the affected field
You need a response that actually contains the ZonedDateTime field configured with the custom serializer.
For example:
@GetMapping("/api/example")
public PValidationResult example() {
return new ValidationResult("ALLOW", ZonedDateTime.now());
}
If the endpoint does not serialize the affected field, the serializer will not be initialized.
3) Add a breakpoint in SpringHandlerInstantiator
Place a breakpoint in Spring’s Jackson integration class:
org.springframework.http.converter.json.SpringHandlerInstantiator
The interesting methods are usually around serializer/deserializer creation, such as:
serializerInstance(...)
deserializerInstance(...)
When the request is serialized, Jackson may enter this class to instantiate your serializer.
At the breakpoint, inspect:
- the serializer class Jackson is trying to create
- the constructor candidates
- the resolved arguments
- whether
DateTimeFormatteris being treated as an autowired constructor dependency - whether the argument array is empty or unresolved
- whether Spring throws
UnsatisfiedDependencyExceptionorNoSuchBeanDefinitionException
This is often where the production symptom becomes obvious.
4) Add a breakpoint in the serializer constructor
Also add a breakpoint in your custom serializer:
public ZonedDateTimeSerializer(DateTimeFormatter formatter) {
this.formatter = formatter;
}
and, if present, the no-argument constructor:
public ZonedDateTimeSerializer() {
}
This helps answer an important question:
Which constructor is actually being attempted on the cold path?
In one common scenario, you may observe Spring trying one constructor path, failing to resolve DateTimeFormatter, and then Jackson/Spring continuing through another construction path.
That is why the response can still succeed while Dynatrace captures the internal exception.
5) Repeat the same request
Call the same endpoint again.
On the second request, you may not hit the same breakpoints.
That is expected.
Once the serializer has been resolved and cached, Jackson does not need to repeat the same cold construction path for every request.
This is the key difference between:
The serializer configuration is broken
and:
Every request fails
A broken cold path may only be visible when the serializer cache is empty.
A practical local reproduction flow
A very practical workflow is:
- restart the app
- set breakpoints in:
SpringHandlerInstantiator.serializerInstance(...)- your custom
ZonedDateTimeSerializerconstructors - optionally
BeanFactorydependency resolution methods
- call the endpoint once from Postman, Insomnia, curl, or MockMvc
- inspect whether Spring tries to resolve
DateTimeFormatter - call the endpoint again
- compare first request behavior vs second request behavior
With curl:
curl -i http://localhost:8080/api/example
curl -i http://localhost:8080/api/example
The first call is the one that matters most.
The root cause pattern
The root cause is usually a combination of these:
- A custom serializer is referenced by annotation or registered in Jackson
- The serializer has a constructor that Spring tries to autowire
- The constructor requires
DateTimeFormatter - No explicit
DateTimeFormatterbean exists, or the dependency is ambiguous - Jackson hits this serializer only on a cold path
- After the serializer cache is warm, the symptom becomes hard to reproduce
The actual exception often points at Spring dependency resolution, but the trigger is JSON serialization.
That makes the issue easy to misdiagnose as:
- a random Spring bean problem
- a slow controller
- a networking issue
- a database issue
- a one-off Jackson weirdness
A minimal example of the problematic setup
public class ZonedDateTimeSerializer extends JsonSerializer<ZonedDateTime> {
private final DateTimeFormatter formatter;
public ZonedDateTimeSerializer(DateTimeFormatter formatter) {
this.formatter = formatter;
}
@Override
public void serialize(
ZonedDateTime value,
JsonGenerator gen,
SerializerProvider serializers
) throws IOException {
if (value == null) {
gen.writeNull();
return;
}
gen.writeString(formatter.format(value));
}
}
Used like this:
public class ApiResponse {
@JsonSerialize(using = ZonedDateTimeSerializer.class)
private ZonedDateTime evaluatedAt;
// getters/setters
}
This looks innocent, but Spring may try to resolve:
DateTimeFormatter formatter
as a bean.
If the bean is missing, the first serializer construction path can fail.
Fix option 1: make the serializer self-contained
If the format is fixed and does not need configuration, the simplest solution is to remove the Spring dependency entirely.
public class ZonedDateTimeSerializer extends JsonSerializer<ZonedDateTime> {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
public ZonedDateTimeSerializer() {
}
@Override
public void serialize(
ZonedDateTime value,
JsonGenerator gen,
SerializerProvider serializers
) throws IOException {
if (value == null) {
gen.writeNull();
return;
}
gen.writeString(FORMATTER.format(value));
}
}
This is boring, but very reliable.
A serializer with a no-argument constructor is easy for Jackson to instantiate and does not depend on Spring’s bean resolution.
Use this when:
- the format is stable
- you do not need environment-specific formatting
- you want fewer moving parts in serialization
Fix option 2: provide an explicit formatter bean
If you do want the formatter to be managed by Spring, make the dependency explicit and unambiguous.
@Configuration
public class DateTimeFormatConfig {
@Bean("apiZonedDateTimeFormatter")
public DateTimeFormatter apiZonedDateTimeFormatter() {
return DateTimeFormatter.ISO_OFFSET_DATE_TIME;
}
}
Then inject it with a qualifier:
public class ZonedDateTimeSerializer extends JsonSerializer<ZonedDateTime> {
private final DateTimeFormatter formatter;
public ZonedDateTimeSerializer(
@Qualifier("apiZonedDateTimeFormatter") DateTimeFormatter formatter
) {
this.formatter = formatter;
}
@Override
public void serialize(
ZonedDateTime value,
JsonGenerator gen,
SerializerProvider serializers
) throws IOException {
if (value == null) {
gen.writeNull();
return;
}
gen.writeString(formatter.format(value));
}
}
This makes the hidden dependency visible.
It also avoids relying on “whatever DateTimeFormatter bean happens to exist”.
Fix option 3: register the serializer through a Jackson module
Another clean approach is to register the serializer yourself instead of relying on field-level annotation discovery.
@Configuration
public class JacksonDateTimeConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer dateTimeCustomizer(
@Qualifier("apiZonedDateTimeFormatter") DateTimeFormatter formatter
) {
return builder -> builder.serializerByType(
ZonedDateTime.class,
new ZonedDateTimeSerializer(formatter)
);
}
}
This is useful when you want one consistent API-wide ZonedDateTime format.
It also makes the relationship between:
ObjectMapperZonedDateTime- your serializer
- your formatter
visible in one configuration class.
What I would avoid
1) Randomly removing date type annotations
If the serializer issue comes from generated code, it may be tempting to remove all custom date annotations.
That can “fix” the exception, but it may also change your API contract.
For example:
-
ZonedDateTimemay becomeDate - timezone information may be lost
- response format may change
- clients may parse the timestamp differently
That might be acceptable in some internal APIs, but it should be treated as a contract change.
2) Adding a global DateTimeFormatter bean without naming it
This may work today:
@Bean
public DateTimeFormatter dateTimeFormatter() {
return DateTimeFormatter.ISO_OFFSET_DATE_TIME;
}
But later, another team adds a second formatter bean and your serializer becomes ambiguous.
Prefer a named bean and a qualifier.
3) Ignoring the exception because the response is still 200
This is the worst option.
A successful response with an exception captured by Dynatrace is still a bug signal.
It can mean:
- wasted latency
- noisy monitoring
- hidden fallback behavior
- first-request instability after deploys
How to test it
The important part is to test the cold path.
Do not only test the second request.
A simple integration test can trigger serialization through MockMvc:
@SpringBootTest
@AutoConfigureMockMvc
class ZonedDateTimeSerializationTest {
@Autowired
private MockMvc mockMvc;
@Test
void firstSerialization_shouldNotThrowSerializerInstantiationError() throws Exception {
mockMvc.perform(get("/api/example"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.evaluatedAt").exists());
}
}
But this only proves the response succeeds.
To catch the real problem, you also want to assert that no serializer-instantiation exception is logged or captured.
If your production symptom was “Dynatrace captures an exception but app logs do not”, a normal log assertion may not be enough.
In that case, add a focused ObjectMapper serialization test and use breakpoints while developing the fix. For automated regression coverage, test the final expected behavior:
@SpringBootTest
class ObjectMapperColdSerializationTest {
@Autowired
private ObjectMapper objectMapper;
@Test
void serializesZonedDateTimeWithoutSpringInstantiationFailure() throws Exception {
ApiResponse response = new ApiResponse();
response.setEvaluatedAt(ZonedDateTime.now(ZoneOffset.UTC));
String json = objectMapper.writeValueAsString(response);
assertThat(json).contains("evaluatedAt");
}
}
If you can attach a Logback ListAppender to relevant Spring/Jackson loggers, also assert that captured logs do not contain:
Unsatisfied dependency
ZonedDateTimeSerializer
DateTimeFormatter
But remember: an internally caught exception may never be emitted to the logger you are capturing. That is exactly why the local breakpoint reproduction is so useful.
Why this belongs in production readiness checks
Serialization is often treated as the last step of a request.
But from an API consumer’s perspective, serialization is part of the contract.
A service method can complete successfully, the database transaction can commit, and then the response can still fail or slow down during JSON conversion.
That is why production-grade APIs should test:
- request deserialization
- response serialization
- error serialization
- cold ObjectMapper paths
- custom serializer/deserializer construction
Especially if your models are generated from OpenAPI or use custom x- vendor extensions.
Debugging checklist
When you see a serializer dependency error, ask:
- Is the serializer referenced by
@JsonSerializeor registered globally? - Does the serializer have multiple constructors?
- Does any constructor require Spring beans?
- Is
SpringHandlerInstantiatorinvolved in the stack trace? - Is the missing dependency something like
DateTimeFormatter? - Does the issue only happen after restart, deploy, or first request?
- Does the response still succeed because of a fallback path?
- Do repeated requests stop showing the error because the serializer is cached?
- Is the exception visible in Dynatrace but missing from normal logs?
If several answers are “yes”, you are probably looking at a cold serializer initialization problem.
The fix I prefer
For most APIs, I prefer this order:
- Keep serializers self-contained when formatting is fixed
- If Spring dependency is needed, provide a named bean +
@Qualifier - Register serializer behavior in one Jackson configuration class
- Add a cold serialization test
- Reproduce once locally with breakpoints in
SpringHandlerInstantiator - Use Dynatrace/APM evidence seriously, even if normal logs are quiet
The actual best choice depends on your API contract.
But the worst choice is to leave serializer construction implicit and hope the first request behaves.
Top comments (0)