DEV Community

Thellu
Thellu

Posted on

The Slow First Request: Debugging a Jackson `ZonedDateTime` Serializer Cold Path in Spring Boot

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 DateTimeFormatter dependency 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
) {}
Enter fullscreen mode Exit fullscreen mode

And somewhere in your model or generated code, the field is configured with a custom serializer:

@JsonSerialize(using = ZonedDateTimeSerializer.class)
private ZonedDateTime evaluatedAt;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. inspect response type
  2. discover field annotations
  3. resolve serializer class
  4. instantiate serializer
  5. cache the serializer
  6. 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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The interesting methods are usually around serializer/deserializer creation, such as:

serializerInstance(...)
deserializerInstance(...)
Enter fullscreen mode Exit fullscreen mode

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 DateTimeFormatter is being treated as an autowired constructor dependency
  • whether the argument array is empty or unresolved
  • whether Spring throws UnsatisfiedDependencyException or NoSuchBeanDefinitionException

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;
}
Enter fullscreen mode Exit fullscreen mode

and, if present, the no-argument constructor:

public ZonedDateTimeSerializer() {
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and:

Every request fails
Enter fullscreen mode Exit fullscreen mode

A broken cold path may only be visible when the serializer cache is empty.


A practical local reproduction flow

A very practical workflow is:

  1. restart the app
  2. set breakpoints in:
    • SpringHandlerInstantiator.serializerInstance(...)
    • your custom ZonedDateTimeSerializer constructors
    • optionally BeanFactory dependency resolution methods
  3. call the endpoint once from Postman, Insomnia, curl, or MockMvc
  4. inspect whether Spring tries to resolve DateTimeFormatter
  5. call the endpoint again
  6. compare first request behavior vs second request behavior

With curl:

curl -i http://localhost:8080/api/example
curl -i http://localhost:8080/api/example
Enter fullscreen mode Exit fullscreen mode

The first call is the one that matters most.


The root cause pattern

The root cause is usually a combination of these:

  1. A custom serializer is referenced by annotation or registered in Jackson
  2. The serializer has a constructor that Spring tries to autowire
  3. The constructor requires DateTimeFormatter
  4. No explicit DateTimeFormatter bean exists, or the dependency is ambiguous
  5. Jackson hits this serializer only on a cold path
  6. 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));
  }
}
Enter fullscreen mode Exit fullscreen mode

Used like this:

public class ApiResponse {

  @JsonSerialize(using = ZonedDateTimeSerializer.class)
  private ZonedDateTime evaluatedAt;

  // getters/setters
}
Enter fullscreen mode Exit fullscreen mode

This looks innocent, but Spring may try to resolve:

DateTimeFormatter formatter
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This is useful when you want one consistent API-wide ZonedDateTime format.

It also makes the relationship between:

  • ObjectMapper
  • ZonedDateTime
  • 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:

  • ZonedDateTime may become Date
  • 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;
}
Enter fullscreen mode Exit fullscreen mode

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());
  }
}
Enter fullscreen mode Exit fullscreen mode

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");
  }
}
Enter fullscreen mode Exit fullscreen mode

If you can attach a Logback ListAppender to relevant Spring/Jackson loggers, also assert that captured logs do not contain:

Unsatisfied dependency
ZonedDateTimeSerializer
DateTimeFormatter
Enter fullscreen mode Exit fullscreen mode

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:

  1. Is the serializer referenced by @JsonSerialize or registered globally?
  2. Does the serializer have multiple constructors?
  3. Does any constructor require Spring beans?
  4. Is SpringHandlerInstantiator involved in the stack trace?
  5. Is the missing dependency something like DateTimeFormatter?
  6. Does the issue only happen after restart, deploy, or first request?
  7. Does the response still succeed because of a fallback path?
  8. Do repeated requests stop showing the error because the serializer is cached?
  9. 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:

  1. Keep serializers self-contained when formatting is fixed
  2. If Spring dependency is needed, provide a named bean + @Qualifier
  3. Register serializer behavior in one Jackson configuration class
  4. Add a cold serialization test
  5. Reproduce once locally with breakpoints in SpringHandlerInstantiator
  6. 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)