Skip to content

Cache compiled serializers across encapsulated contexts#84

Open
jagould2012 wants to merge 1 commit intofastify:mainfrom
jagould2012:v5.0.3-patched
Open

Cache compiled serializers across encapsulated contexts#84
jagould2012 wants to merge 1 commit intofastify:mainfrom
jagould2012:v5.0.3-patched

Conversation

@jagould2012
Copy link
Copy Markdown

Cache compiled serializers across encapsulated contexts

Problem

SerializerSelector creates a new buildSerializerFactory per Fastify encapsulated context (plugin). Each factory compiles serializers independently via fast-json-stringify, so routes in different plugins that share the same response schema each trigger a full compilation from scratch.

In Fastify applications with encapsulated route plugins (the standard pattern), every plugin gets its own compiler instance. If 45 controllers each define routes returning { $ref: "user.json#" }, the user.json schema is compiled into a serializer 45 separate times — producing identical output each time.

How it happens

SerializerSelector()
  └─ buildSerializerFactory() called per encapsulated context
       └─ responseSchemaCompiler() called per route response schema
            └─ fastJsonStringify(schema, fjsOpts)  ← no cache, always rebuilds

Each buildSerializerFactory invocation returns a fresh responseSchemaCompiler.bind(null, fjsOpts). There is no shared state between factories, so identical schemas are compiled repeatedly.

Impact

In a production Fastify service with 45 controllers (271 routes, 90 unique response schemas):

Metric Current With cache
fastJsonStringify() calls 450 90
Heap after ready() 961 MB 278 MB
RSS after ready() 920+ MB 470 MB

The 450 → 90 reduction comes from deduplicating identical schemas across encapsulated contexts. Within a single controller, routes like GET /:id, POST, PUT, PATCH that return the same entity schema already share one compilation (5 routes → 2 unique: entity + list wrapper). The cross-controller duplication is what this fix addresses.

Fastify's own TODO acknowledges this

In fastify/lib/reply.js (lines 382-383):

// TODO: Explore a central cache for all the schemas shared across
// encapsulated contexts

Fix

Move the serializer cache from per-factory scope to SerializerSelector scope, so compiled serializers are shared across all encapsulated contexts.

 function SerializerSelector () {
+  const cache = new Map()
   return function buildSerializerFactory (externalSchemas, serializerOpts) {
     const fjsOpts = Object.assign({}, serializerOpts, { schema: externalSchemas })
-    return responseSchemaCompiler.bind(null, fjsOpts)
+    return function cachedResponseSchemaCompiler (opts) {
+      const key = JSON.stringify(opts.schema)
+      const cached = cache.get(key)
+      if (cached) return cached
+      const result = responseSchemaCompiler(fjsOpts, opts)
+      cache.set(key, result)
+      return result
+    }
   }
 }

Why JSON.stringify(opts.schema) is the right cache key

  • Response schemas passed to the compiler are plain JSON Schema objects
  • Two routes with { $ref: "user.json#" } produce identical JSON.stringify output
  • The externalSchemas (registered via addSchema()) are the same across all contexts in a Fastify instance, so the resolved output is identical regardless of which factory compiled it
  • JSON.stringify handles nested objects, $ref strings, and anyOf/oneOf arrays correctly

Why this is safe

  • The compiled serializer function is stateless — it takes input and returns JSON. Sharing it across routes has no side effects.
  • The cache lives for the lifetime of the SerializerSelector instance, which is the lifetime of the Fastify server. No memory leak concern — the number of unique schemas is bounded and known at startup.
  • If two schemas stringify to the same key but have different external schema contexts, the first compilation wins. In practice this never happens because addSchema() is called before route registration, and all contexts see the same external schemas.

Test Results

All 11 tests pass (10 existing + 1 new cache test). 100% coverage across statements, branches, functions, and lines.

> @fastify/fast-json-stringify-compiler@5.0.3 test
> npm run unit && npm run test:typescript

✔ Use input schema duplicate in the externalSchemas (9.090333ms)
✔ basic usage (7.159209ms)
✔ cache hit for identical schemas across factories (2.1035ms)
✔ fastify integration (37.740292ms)
▶ standalone
  ✔ errors (1.538084ms)
  ▶ generate standalone code
    ✔ usage standalone code (0.351875ms)
  ✔ generate standalone code (8.797458ms)
  ✔ fastify integration - writeMode (28.12275ms)
  ✔ fastify integration - writeMode forces standalone (1.953042ms)
  ✔ fastify integration - readMode (8.611958ms)
✔ standalone (52.749584ms)
ℹ tests 11
ℹ pass 11
ℹ fail 0

---------------|---------|----------|---------|---------|
File           | % Stmts | % Branch | % Funcs | % Lines |
---------------|---------|----------|---------|---------|
All files      |     100 |      100 |     100 |     100 |
 index.js      |     100 |      100 |     100 |     100 |
 standalone.js |     100 |      100 |     100 |     100 |
---------------|---------|----------|---------|---------|

> tsd ✔

New test: cache hit for identical schemas across factories

test('cache hit for identical schemas across factories', t => {
  t.plan(2)
  const factory = FjsCompiler()

  // Simulate two encapsulated contexts getting separate factories
  const compiler1 = factory(externalSchemas1, fastifyFjsOptionsDefault)
  const compiler2 = factory(externalSchemas1, fastifyFjsOptionsDefault)

  const serialize1 = compiler1({ schema: sampleSchema })
  const serialize2 = compiler2({ schema: sampleSchema })

  // Same function reference returned from cache
  t.assert.equal(serialize1, serialize2)
  t.assert.equal(serialize1({ name: 'cached' }), '{"name":"cached"}')
})

Related

  • fastify/fast-json-stringify#836 — Companion PR that extracts reusable functions for non-recursive $ref schemas within a single serializer compilation. That fix reduces per-serializer code size; this fix reduces the number of serializers compiled. Together they address both dimensions of the memory bloat.

Reproduction

const Fastify = require('fastify')

const app = Fastify()

app.addSchema({
  $id: 'user.json',
  type: 'object',
  properties: {
    name: { type: 'string' },
    email: { type: 'string' }
  }
})

// 45 plugins, each with routes returning the same schema
for (let i = 0; i < 45; i++) {
  app.register(async function plugin (fastify) {
    fastify.get(`/entity-${i}/:id`, {
      schema: { response: { 200: { $ref: 'user.json#' } } }
    }, async () => ({ name: 'test', email: 'test@test.com' }))
  })
}

await app.ready()
console.log('Heap:', Math.round(process.memoryUsage().heapUsed / 1024 / 1024), 'MB')
// Without cache: schema compiled 45 times
// With cache: schema compiled 1 time, reused 44 times

Copy link
Copy Markdown
Member

@gurgunday gurgunday left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

But i'd appreciate more reviews for this

@gurgunday gurgunday requested review from a team April 9, 2026 21:12
Copy link
Copy Markdown
Member

@Eomm Eomm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks the encapsulation.

I think we have few options here:

  1. stringify the externalSchemas
  2. add a perfomance option so users that do not have the encapsulation issue can enable it (and a blog post is required)

You have measured the RAM only, could you share the time to start too?

Plus I think the fastify repo will fail with this feature as well, I think/hope this scenario is covered by the tests

const app = require('fastify')({ logger: true })


app.register(async function plugin (app, opts) {

  app.addSchema({
    $id: 'user',
    type: 'object',
    additionalProperties: false,
    properties: {
      id: { type: 'string' },
      type: { type: 'string' },
    }
  })

  app.get('/a', {
    schema: {
      response: {
        200: {
          $ref: 'user#'
        }
      }
    }
  }, () => {
    return {
      id: '123',
      type: 'admin',
      hello: 'world'
    }
  })




})

app.register(async function plugin (app, opts) {


  app.addSchema({
    $id: 'user',
    type: 'object',
    additionalProperties: false,
    properties: {
      id: { type: 'string' },
      type: { type: 'string' },
      hello: { type: 'string' },
    }
  })

  app.get('/b', {
    schema: {
      response: {
        200: {
          $ref: 'user#'
        }
      }
    }
  }, () => {
    return {
      id: '123',
      type: 'admin',
      hello: 'world'
    }
  })
})


async function run () {
  const a = await app.inject('/a')
  console.log('a', a.json())

  const b = await app.inject('/b')
  console.log('b', b.json())

}

run()

Without the change:

a { id: '123', type: 'admin' }
b { id: '123', type: 'admin', hello: 'world' }

With the change:

a { id: '123', type: 'admin' }
b { id: '123', type: 'admin' }

@jagould2012
Copy link
Copy Markdown
Author

Start time was 81% faster. I included on the other PR

fastify/fast-json-stringify#836

As for the encapsulation issue, the PR should only re-use schemas with the same $id, which would always be the same anyway? I don't think fastify will allow you to have different schemas with the same $id in different contexts? The schema registry spans encapsulation already?

@Eomm
Copy link
Copy Markdown
Member

Eomm commented Apr 10, 2026

As for the encapsulation issue, the PR should only re-use schemas with the same $id, which would always be the same anyway?

Please, review my snippet above: the $id: 'user' schema is different on different contexts.

I don't think fastify will allow you to have different schemas with the same $id in different contexts?

You can't have same $id in the same context, but 2 contexts are isolated by design so you can have the same $id with a different configuration on different context (as the snippet does)

@jagould2012
Copy link
Copy Markdown
Author

Maybe I'm not understanding the intervals of Fastify. How does one have multiple contexts with different schemas on the same $id? I see your example above, but not sure how to recreate it in a real use of fastify?

@jagould2012
Copy link
Copy Markdown
Author

And further, is there a way to make the reuse in the PR work just in one context? I have a single plugin loading all my schemas, so I don't really need it to work outside of encapsulation. I just need it to not create hundreds of duplicate serializers inside the one context, which is what is happening now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants