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: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: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 called3
Recursive Call
The converter called
JsonSerializer.Serialize(writer, value, options)
with the same options4
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 callJsonSerializer.Serialize()
or JsonSerializer.Deserialize()
from within a converter, you must be careful about which options you pass.
The most common solutions were:
- Pass no options:
JsonSerializer.Serialize(writer, value)
- loses all configuration - Create new options: Build a fresh
JsonSerializerOptions
without converters - Use proxy types: Create wrapper types without the
[JsonConverter]
attribute - Manual serialization: Write JSON properties manually without using the serializer
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
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:- ✅ 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.