Skip to main content
You’re deep in a complex C# project when suddenly your JSON serialization starts throwing StackOverflowException. The stack trace shows your custom JsonConverter calling itself infinitely. Sound familiar? This is the story of how we discovered, diagnosed, and solved a subtle but critical bug in our JSON serialization pipeline that was causing documentation generation to fail spectacularly - and how our solution can prevent similar issues in any System.Text.Json-based application.
This problem affects any System.Text.Json custom converter that calls JsonSerializer.Serialize() or JsonSerializer.Deserialize() with the same JsonSerializerOptions that contains the converter itself.

The Problem: When Good Converters Go Bad

Our DotNetDocs system uses a sophisticated JSON serialization pipeline for handling Mintlify documentation configuration. We had custom converters for polymorphic types like icons (which could be simple strings or complex objects), API configurations, and background images. Everything worked perfectly until we started implementing ServerConfig support with implicit operators, following the same pattern as our successful IconConfig implementation. That’s when the documentation generation process started crashing with stack overflow errors during the final serialization step. Here’s what a typical problematic converter looked like:
The issue was subtle but deadly: both the Read and Write methods were passing the same options parameter back to JsonSerializer, which included the converter itself, creating infinite recursion.

The Diagnosis: Stack Overflow Detective Work

Initially, we thought the problem was with our new ServerConfig implementation. The error messages pointed to MdxConfig.Server serialization failures, and the timing coincided with our ServerConfig changes. But as we dug deeper, we realized this was a fundamental flaw in how we were implementing our custom converters. The stack trace revealed the true culprit:
The recursion happened because:
1

Converter Registration

Our converter was registered in MintlifyConstants.JsonSerializerOptions
2

Serialization Request

When serializing an object containing an IconConfig, the converter’s Write method was called
3

Recursive Call

The converter called JsonSerializer.Serialize(writer, value, options) with the same options
4

Self-Invocation

Since the options contained the same converter, it immediately called the converter again
5

Infinite Loop

This process repeated until the stack overflowed

The Research: Learning from the Community

A deep dive into System.Text.Json documentation and Stack Overflow revealed this is a well-known problem. The core issue is that when you call JsonSerializer.Serialize() or JsonSerializer.Deserialize() from within a converter, you must be careful about which options you pass. The most common solutions were:
  1. Pass no options: JsonSerializer.Serialize(writer, value) - loses all configuration
  2. Create new options: Build a fresh JsonSerializerOptions without converters
  3. Use proxy types: Create wrapper types without the [JsonConverter] attribute
  4. Manual serialization: Write JSON properties manually without using the serializer
Each approach had trade-offs, but none felt elegant for our complex nested object scenarios.

Our Innovation: Self-Excluding Options

The breakthrough came from a simple but powerful insight: what if each converter maintained its own copy of the serialization options with itself removed? This approach would:
  • ✅ Preserve all other converters for nested objects
  • ✅ Maintain the same configuration (naming policies, etc.)
  • ✅ Be thread-safe through lazy initialization
  • ✅ Be testable and verifiable
Here’s our solution:

Why This Approach Works

Converter Isolation

Each converter creates options that exclude only itself, preventing direct recursion while preserving all other converters

Nested Object Support

Complex objects can still use other converters for their properties (like GroupConfig with Icon properties)

Configuration Preservation

All JsonSerializerOptions settings (naming policy, null handling, etc.) are preserved

Thread Safety

Lazy initialization ensures the options are created once and safely shared across threads

Verification Through Testing

To ensure our solution worked correctly, we created comprehensive unit tests that verify both the recursion prevention and the functional correctness:
These tests proved that:
  • ✅ The converter exclusion logic works correctly
  • ✅ Nested objects serialize without stack overflow
  • ✅ Both simple and complex serialization scenarios work
  • ✅ Configuration settings are preserved

Real-World Impact

The fix resolved several critical issues in our documentation generation pipeline:

Performance Benefits

Lazy Initialization

The options are computed once per converter type and cached, making subsequent serializations very fast. The overhead is minimal - just one additional property access.

Memory Efficiency

Each converter type maintains only one additional JsonSerializerOptions instance, and the lazy initialization ensures they’re only created when actually needed.

Implementation Guide

To implement this pattern in your own converters:
1

Add the Options Field

Create a lazy-initialized field that copies your main options and removes the current converter:
2

Create the Property

Add a property (make it internal for testing):
3

Update Read/Write Methods

Replace any calls to JsonSerializer that pass the original options:
4

Add Unit Tests

Create tests to verify the converter exclusion works and serialization doesn’t cause stack overflow

Anti-Patterns to Avoid

Advanced Considerations

Multiple Converter Dependencies

If your converters depend on each other, you might need more sophisticated exclusion logic:

Framework Compatibility

This pattern works across all .NET versions that support System.Text.Json, including:
  • ✅ .NET 8.0+
  • ✅ .NET Core 3.1+
  • ✅ .NET Framework 4.6.2+ (with NuGet package)

Looking Forward: Preventing Future Issues

This experience taught us valuable lessons about System.Text.Json converter development:

Always Test Recursion

Every custom converter should have unit tests that verify it doesn’t cause stack overflow in nested scenarios

Options Management

Maintain clear patterns for how JsonSerializerOptions are used within converters

Lazy Initialization

Use lazy initialization for computed options to ensure thread safety and performance

Internal Testability

Make critical internal logic testable with internal access modifiers

Conclusion

The infinite recursion bug in our JSON converters was a subtle but critical issue that completely broke our documentation generation pipeline. What started as a mysterious stack overflow turned into an opportunity to create a robust, testable solution that prevents similar issues across our entire codebase. The key insight was that converters need to exclude themselves from recursive serialization calls while preserving all other configuration. Our self-excluding options pattern achieves this elegantly with minimal performance overhead and maximum maintainability. This isn’t just about fixing a bug - it’s about understanding the fundamental principles of how System.Text.Json converters work and building resilient patterns that prevent entire categories of problems. The lazy-initialized, self-excluding options approach can be applied to any custom converter that needs to call back into the serializer.
The complete source code for our converter implementations is available in the DotNetDocs repository, including all unit tests that verify the recursion prevention works correctly.

Special thanks to the System.Text.Json team and the broader .NET community whose documentation and Stack Overflow answers helped us understand the underlying principles that made this solution possible.