Taming System.Text.Json Infinite Recursion in Custom Converters
How we solved a critical infinite recursion bug in JSON serialization that was causing stack overflows during documentation generation, and the elegant solution that prevents it.
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.
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:
Copy
Ask AI
public class IconConverter : JsonConverter<IconConfig>{ public override IconConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { return reader.TokenType switch { JsonTokenType.String => new IconConfig { Name = reader.GetString() ?? string.Empty }, JsonTokenType.StartObject => JsonSerializer.Deserialize<IconConfig>(ref reader, options), // ⚠️ Problem! JsonTokenType.Null => null, _ => throw new JsonException($"Unexpected token type for icon: {reader.TokenType}") }; } public override void Write(Utf8JsonWriter writer, IconConfig? value, JsonSerializerOptions options) { if (value is null) { writer.WriteNullValue(); return; } // If only Name is set (simple icon), write as string if (!string.IsNullOrWhiteSpace(value.Name) && string.IsNullOrWhiteSpace(value.Library) && string.IsNullOrWhiteSpace(value.Style)) { writer.WriteStringValue(value.Name); } else { // Write as object for complex configurations JsonSerializer.Serialize(writer, value, options); // ⚠️ Problem! } }}
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.
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:
Copy
Ask AI
Stack overflow. at Mintlify.Core.Converters.IconConverter.Read(...) at System.Text.Json.JsonSerializer.Deserialize<IconConfig>(...) at Mintlify.Core.Converters.IconConverter.Read(...) at System.Text.Json.JsonSerializer.Deserialize<IconConfig>(...) [... repeats infinitely]
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
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:
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
Each approach had trade-offs, but none felt elegant for our complex nested object scenarios.
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:
Copy
Ask AI
public class IconConverter : JsonConverter<IconConfig>{ #region Private Fields /// <summary> /// Lazy-initialized JsonSerializerOptions that excludes this converter to prevent infinite recursion. /// </summary> private static readonly Lazy<JsonSerializerOptions> _optionsWithoutThis = new Lazy<JsonSerializerOptions>(() => { var options = new JsonSerializerOptions(MintlifyConstants.JsonSerializerOptions); // Remove this converter to prevent recursion for (int i = options.Converters.Count - 1; i >= 0; i--) { if (options.Converters[i] is IconConverter) { options.Converters.RemoveAt(i); } } return options; }); /// <summary> /// Gets the JsonSerializerOptions instance without this converter to prevent infinite recursion. /// </summary> internal static JsonSerializerOptions OptionsWithoutThis => _optionsWithoutThis.Value; #endregion public override IconConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { return reader.TokenType switch { JsonTokenType.String => new IconConfig { Name = reader.GetString() ?? string.Empty }, JsonTokenType.StartObject => JsonSerializer.Deserialize<IconConfig>(ref reader, OptionsWithoutThis), // ✅ Fixed! JsonTokenType.Null => null, _ => throw new JsonException($"Unexpected token type for icon: {reader.TokenType}") }; } public override void Write(Utf8JsonWriter writer, IconConfig? value, JsonSerializerOptions options) { if (value is null) { writer.WriteNullValue(); return; } if (!string.IsNullOrWhiteSpace(value.Name) && string.IsNullOrWhiteSpace(value.Library) && string.IsNullOrWhiteSpace(value.Style)) { writer.WriteStringValue(value.Name); } else { JsonSerializer.Serialize(writer, value, OptionsWithoutThis); // ✅ Fixed! } }}
To ensure our solution worked correctly, we created comprehensive unit tests that verify both the recursion prevention and the functional correctness:
Copy
Ask AI
[TestMethod]public void OptionsWithoutThis_ExcludesIconConverter(){ var originalOptions = MintlifyConstants.JsonSerializerOptions; var optionsWithoutThis = IconConverter.OptionsWithoutThis; // Should not be the same instance optionsWithoutThis.Should().NotBeSameAs(originalOptions); // Original should have IconConverter originalOptions.Converters.Should().Contain(c => c is IconConverter); // OptionsWithoutThis should NOT have IconConverter optionsWithoutThis.Converters.Should().NotContain(c => c is IconConverter); // Should preserve other important settings optionsWithoutThis.PropertyNamingPolicy.Should().Be(originalOptions.PropertyNamingPolicy); optionsWithoutThis.DefaultIgnoreCondition.Should().Be(originalOptions.DefaultIgnoreCondition);}[TestMethod]public void Serialize_GroupConfigWithIcon_NoStackOverflow(){ var group = new GroupConfig { Group = "API Reference", Icon = new IconConfig { Name = "folder" } }; var act = () => JsonSerializer.Serialize(group, MintlifyConstants.JsonSerializerOptions); act.Should().NotThrow(); // This would throw StackOverflowException before the fix var json = act(); json.Should().Contain("\"group\": \"API Reference\""); json.Should().Contain("\"icon\": \"folder\"");}
These tests proved that:
✅ The converter exclusion logic works correctly
✅ Nested objects serialize without stack overflow
✅ Both simple and complex serialization scenarios work
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.
Create a lazy-initialized field that copies your main options and removes the current converter:
Copy
Ask AI
private static readonly Lazy<JsonSerializerOptions> _optionsWithoutThis = new Lazy<JsonSerializerOptions>(() => { var options = new JsonSerializerOptions(YourConstants.JsonSerializerOptions); for (int i = options.Converters.Count - 1; i >= 0; i--) { if (options.Converters[i] is YourConverter) { options.Converters.RemoveAt(i); } } return options; });
// Passing the same options that contain your converterJsonSerializer.Serialize(writer, value, options);// Creating options without any convertersJsonSerializer.Serialize(writer, value, new JsonSerializerOptions());// Manually checking for recursion with static flagsprivate static bool _isSerializing = false;
Copy
Ask AI
// Use options that exclude your converterJsonSerializer.Serialize(writer, value, OptionsWithoutThis);// Preserve configuration while excluding problematic convertersvar safeOptions = new JsonSerializerOptions(originalOptions);// ... remove only the problematic converters// Use lazy initialization for thread safety and performanceprivate static readonly Lazy<JsonSerializerOptions> _safeOptions = ...
If your converters depend on each other, you might need more sophisticated exclusion logic:
Copy
Ask AI
private static readonly Lazy<JsonSerializerOptions> _optionsWithoutThis = new Lazy<JsonSerializerOptions>(() => { var options = new JsonSerializerOptions(MintlifyConstants.JsonSerializerOptions); // Remove converters that could cause recursion for (int i = options.Converters.Count - 1; i >= 0; i--) { var converter = options.Converters[i]; if (converter is IconConverter or RelatedConverter) { options.Converters.RemoveAt(i); } } return options; });
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.