If you are using Fastify, you already know that it is the fastest Node.js web framework in town.
But we know that a benchmark is something different from a real-world application. The benchmark has the fastest startup time possible because it does nothing before the server listens.
In fact, you should take care of two measurements:
- The time it takes to start the server and accept the first request.
- The time it takes to process the request.
Fastify helps us to reduce the time it takes to process the request, but it needs a little bit of time to start the server. So, is there something that can help Fastify to speed up the startup? Let's see how to do it in this article using a special Fastify configuration!
Why should you care about startup time?
I will try to list some reasons why you should care about startup time:
- Development environment: A faster startup improves the developer experience and productivity.
- Cloud deployment: having a faster startup reduces the infrastructure cost when you deploy Fastify to a Lambda.
- Reduced deploy time: if you roll out a new version of your application that fixes a BIG bug, you will ship it faster.
So did you find a reason to care about startup time? I hope you did!
How to speed up the startup?
A real-world application has many things to do when it starts. Usually, the operations are:
- Connect to a database
- Preload application data (cache, configuration, ...)
- Start the Fastify server
All these operations are time-wasting and unavoidable because it is necessary to implement our business logic. If we analyze the list of operations, we can see that there are two groups of operations:
- Architecture operations: these are the operations that are related to the application architecture.
- Framework operations: these are the operations that are related to the application framework, aka Fastify.
Let's see them in detail.
Architecture operations
Architecture operations are the operations that are time-wasting and unavoidable because they provide all the stuff that the application needs to run:
- Connect to a database
- Preloaded data (cache, configuration, ...)
If you need to optimize these operations, you need to optimize the application architecture. Talking about application architecture is out of the scope of this article, but you can find a lot of information about it in the Fastify book coming soon.
Framework operations
The framework operations are time-wasting but you can reduce them by using a special configuration! By knowing the internal architecture of Fastify, you must know that the heaviest operation during the startup is JSON Schema compilation. That being said, I'm not going to suggest removing the JSON Schema as optimization ๐
If you are not using JSON Schema, you should start because it provides some benefits:
Before optimising the framework operations, you should know why the JSON Schema compilation is the heaviest operation.
JSON Schema compilation workflow
To improve the startup time, you need to be aware of the following workflow:
- Fastify compiles the JSON Schema into a JavaScript function during the startup
- The function is stored in each route context
- Whenever a request is received, the compiled function is executed when needed
Fastify implements this workflow for each JSON Schema you may configure. For example, the following code will compile the JSON Schemas into 3 JavaScript functions:
- One validation function for the
body
- One validation function for the
headers
- One serialize function for the
response
when the status code is 200
app.post('/', {
handler (req) { return { hello: req.body.name } },
schema: {
body: {
type: 'object',
properties: {
name: { type: 'string' }
}
},
headers: {
type: 'object',
properties: {
'x-header-app': { type: 'string' }
}
},
response: {
200: {
type: 'object',
properties: {
hello: { type: 'string' }
}
}
}
}
})
As you can understand, the compilation process increases the startup time progressively while you adds more and more routes to your application.
How to optimize the compile process?
To optimize the compile process we discussed in the previous section, we are going to skip it! The idea is to split the compilation process into two steps:
- Build phase: start the application and store the compiled functions in a JavaScript file. Then turn off the application.
- Read phase: start the application and load the functions from the JavaScript file without processing the JSON Schemas.
In practice, we are introducing a build phase that lets us skip the compilation process. This optimization has the same pitfall that every build step has:
- You must be aware when you want to rebuild the application.
- Editing the JSON Schema source code does not update the compiled functions.
As a counter side the build process has these pros:
- The Framework operation is constant time and predictable.
- The application startup time is reduced.
- The application is not affected by performance issues introduced with new releases.
It is time to code the build phase!
Implementing the build phase
The build phase is the first phase of the build process. Its scope is to compile the JSON Schemas into a JavaScript file.
We will use the schemaController
feature of Fastify to implement this phase.
const fastify = require('fastify')
const fs = require('fs')
// These modules are used internally by Fastify
const { StandaloneValidator } = require('@fastify/ajv-compiler')
const { StandaloneSerializer } = require('@fastify/fast-json-stringify-compiler')
// This module is used to generate a valid filename for the compiled functions
const sanitize = require('sanitize-filename')
const app = fastify(
{
jsonShorthand: false,
schemaController: {
compilersFactory: {
buildValidator: StandaloneValidator({
readMode: false,
storeFunction(routeOpts, schemaValidationCode) {
const fileName = generateFileName(routeOpts)
fs.writeFileSync(fileName, schemaValidationCode)
}
}),
buildSerializer: StandaloneSerializer({
readMode: false,
storeFunction (routeOpts, schemaSerializationCode) {
const fileName = generateFileName(routeOpts)
fs.writeFileSync(fileName, schemaSerializationCode)
}
}),
}
}
}
)
// use your app as you normally would
// ... app.register()
// ... app.get()
app.post('/hello', {
handler (req) { return { hello: req.body.name } },
schema: {
body: {
type: 'object',
properties: {
name: { type: 'string' }
}
},
headers: {
type: 'object',
properties: {
'x-header-app': { type: 'string' }
}
},
response: {
200: {
type: 'object',
properties: {
hello: { type: 'string' }
}
}
}
}
})
app.ready().then(() => {
console.log('Compilation completed!')
})
function generateFileName(routeOpts) {
// routeOpts is: { schema, method, url, httpPart } when the schema is for validation
// or: { schema, method, url, httpStatus } when the schema is for serialization
return `./generated-${routeOpts.method}-${routeOpts.httpPart || routeOpts.httpStatus}-${sanitize(routeOpts.url)}.js`
}
Running the previous code with the node app.js
command will generate the following files:
./generated-POST-200-hello.js
./generated-POST-body-hello.js
./generated-POST-headers-hello.js
Note that the application will work as usual when the
readMode
is set tofalse
. The generated functions will persist at every startup.
Great! The compilation process is completed. Let's see how to consume the generated files.
Implementing the read phase
The read phase is the second phase of our optimization process. We need to load the generated files and use them to compile the JSON Schemas.
To do so, we must edit the Fastify schemaController
option to use the generated files:
function restoreFunction (routeOpts) {
const fileName = generateFileName(routeOpts)
return require(path.join(__dirname, fileName))
}
const app = fastify(
{
jsonShorthand: false,
schemaController: {
compilersFactory: {
buildValidator: StandaloneValidator({
readMode: true,
restoreFunction
}),
buildSerializer: StandaloneSerializer({
readMode: true,
restoreFunction
}),
}
}
}
)
As you can see, we have just modified the schemaController
option to use the generated files.
Both the buildValidator
and the buildSerializer
compilers are using the restoreFunction
to load the generated files.
Note that the restoreFunction
must be able to generate the file name from the routeOption
.
This is why we are using the same generateFileName
function to generate the file name during the compilation
and the read access.
You can implement your own
generateFileName
to identify a compiled function. A good option is to use the JSON Schema$id
property if you set it on the schemas.
Now, running the node app.js
command will start the application, and the generated files will be loaded.
If you change the POST /hello
schemas, you must recompile the application to update the generated files.
It is a good practice to compile the JSON Schemas when the schemas are consolidated. During the early stages of the application development, it may be unuseful to compile the schemas every time.
Conclusion
Testing this approach revealed that an application with 100 schemas loads in ~100 milliseconds! This blog post has been inspired by this performance issue.
You have learned how to optimize the JSON Schema compilation process.
Now you may create your own tooling to run the application in readMode: false
when you need to compile
the schemas and with the readMode: true
to load them from the file system.
Comment and share if you enjoyed this article!