Extract reusable functions for non-recursive external $ref schemas#836
Open
jagould2012 wants to merge 1 commit intofastify:mainfrom
Open
Extract reusable functions for non-recursive external $ref schemas#836jagould2012 wants to merge 1 commit intofastify:mainfrom
$ref schemas#836jagould2012 wants to merge 1 commit intofastify:mainfrom
Conversation
Eomm
reviewed
Apr 10, 2026
Member
Eomm
left a comment
There was a problem hiding this comment.
I think the PR description could be transformed into a test
But this PR LGTM
| const fullPath = `${schemaId}#${jsonPointer}` | ||
|
|
||
| if (context.recursivePaths.has(fullPath) || context.buildingSet.has(schema)) { | ||
| if (context.recursivePaths.has(fullPath) || context.buildingSet.has(schema) || (schemaId && schemaId !== '')) { |
Member
There was a problem hiding this comment.
Suggested change
| if (context.recursivePaths.has(fullPath) || context.buildingSet.has(schema) || (schemaId && schemaId !== '')) { | |
| if (context.recursivePaths.has(fullPath) || context.buildingSet.has(schema) || schemaId !== '') { |
| const fullPath = `${schemaId}#${jsonPointer}` | ||
|
|
||
| if (context.recursivePaths.has(fullPath) || context.buildingSet.has(schema)) { | ||
| if (context.recursivePaths.has(fullPath) || context.buildingSet.has(schema) || (schemaId && schemaId !== '')) { |
Member
There was a problem hiding this comment.
Suggested change
| if (context.recursivePaths.has(fullPath) || context.buildingSet.has(schema) || (schemaId && schemaId !== '')) { | |
| if (context.recursivePaths.has(fullPath) || context.buildingSet.has(schema) || schemaId !== '') { |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extract reusable functions for non-recursive external
$refschemasProblem
When a non-recursive schema registered via
$ref(with a$id) is referenced multiple times,fast-json-stringifyinlines the full serialization code at every reference point. ThefunctionsNamesBySchemacache — which correctly extracts reusable functions for recursive schemas — never activates for non-recursive schemas, regardless of how many times they appear.This causes exponential code generation in real-world schemas where:
$ref(e.g. aContactschema referenced by 10+ other schemas)anyOf/oneOfwraps$refto express polymorphic fields (populated object OR string ID OR null)oneOfvariants with further$refrelationshipsIn production schemas, a single route serializer can generate 48+ MB of JavaScript code, with total output across all routes exceeding 800 MB.
Root Cause
In
buildObject()(index.js), the function extraction path at line 571 only triggers when:recursivePaths.has(fullPath)— schema references itself (circular)buildingSet.has(schema)— schema is currently being built (re-entrant)For non-recursive external schemas, neither condition is true. The code falls through to the inline path (line 593), which:
buildingSetbuildingSetThe next time the same
$reftarget is encountered,buildingSetno longer contains it, so it's inlined again. ThefunctionsNamesBySchemaMap is never populated, so the cache check at line 560 always misses.Before (current behavior)
Compounding with
anyOf/oneOfThe problem is amplified by
anyOf/oneOfprocessing inbuildOneOf():anyOfoption is merged with the parent schema viamergeLocations()(line 494)mergeLocations()clones the referenced schema and deletes its$idmergedSchemasIdscache (line 1143) keys byoptionSchemaobject reference, but each{ "$ref": "..." }at a different JSON position is a distinct object — so identicalanyOfpatterns at different properties never share cached mergesA schema with
oneOfcontaining 3 object variants (each with nestedanyOfrefs) causes a 19x code size multiplier at every reference point. Combined with inlining, a single entity referenced 125 times across all schemas produces catastrophic output.Fix
Add a third condition to the function extraction check: extract a function when the schema has a
schemaId(meaning it was resolved from an external$refwith a$id).This causes any externally-registered schema (one that was added via the
schemaoption oraddSchema()) to be extracted into a named function on first encounter, then reused viafunctionsNamesBySchemaon subsequent encounters — the same mechanism that already works correctly for recursive schemas.Why
schemaIdis the right signal$refalways have aschemaId(their$idvalue)schemaIdas''— these continue to be inlined as beforeanyOf/oneOfget synthetic__fjs_merged_*IDs, which also triggers extraction — this is beneficial because merged schemas are the most expensive to regenerateWhy this is safe
The extracted function path (lines 572-590) is already battle-tested — it's the same code path used for every recursive schema today. The only change is that more schemas enter this path. The generated function has identical semantics to the inline code:
Test Results
Tested against a production Fastify service with 90 JSON schemas (35 unique
$reftargets, 252 total$refusages, 250anyOf, 6oneOf).Single complex entity serializer (8-10 relationship fields, 4-5 levels deep)
firstNameinlined copiesAll 45 read schemas (simulating GET /:id routes)
List wrapper schemas (simulating GET / routes with pagination)
Functional correctness
All serialization tests pass with the patch, including:
$ref— populated objects serialize correctlyanyOfwith$ref— polymorphic fields (populated object, string ID, null) all serialize correctlyoneOf— discriminated union variants serialize correctlyanyOffieldsFunctional test used
Reproduction
Minimal script to demonstrate the issue and the fix:
To see the catastrophic scaling, add
oneOfvariants to the contact schema and increase the reference count to 10+. Code size grows multiplicatively with each additionaloneOfvariant × each additional reference.Test Suite
All 468 existing tests pass with zero failures. TypeScript definitions also pass.
Coverage
Benchmarks
npm run benchmark— no performance regression. Results are within noise margin across all categories.Serialization throughput (ops/sec)
All deltas are within the ±5% noise margin of the benchmarking harness. The patch adds no measurable runtime overhead — the function call indirection is negligible compared to the inlined code it replaces.
Full benchmark output (patched)
Impact
This change benefits any Fastify application that:
addSchema()to register shared schemas with$id$reffrom multiple locationsanyOf/oneOfto express polymorphic fields (very common with ORMs like Mongoose, Prisma, etc.)The performance improvement scales with schema complexity — applications with deeply nested cross-referencing schemas see the most dramatic improvement, but even simple schemas with repeated
$reftargets benefit from reduced code duplication.