Chances are high that you've previously worked with Express, as it's been the go-to web framework for Node.js developers since its release in 2010. However, in recent years, newer web frameworks have emerged, and Express's development has slowed down significantly.
Fastify is a relatively new player on the scene, but it's quickly gaining popularity due to its speed and unique features.
If you're still using Express, you might wonder if it's worth switching to Fastify. We've created this three-part series to explore the benefits of switching from Express to Fastify, as well as the potential challenges you might encounter. It'll also provide a hands-on guide for migrating your existing Express applications to Fastify.
In this part, we'll focus on why you might consider Fastify over Express for your next Node.js project and explore some of Fastify's core concepts in detail.
So let's dive in and see what Fastify has to offer!
Prerequisites
This tutorial assumes that you have a recent version of Node.js installed on your computer, such as the latest LTS release (v18.x at the time of writing). Its code samples use top-level await and the ES module syntax instead of CommonJS. We also assume you have a basic knowledge of building Node.js-backed APIs using Express or other web frameworks.
Why Switch from Express to Fastify for Node.js?
Before delving into the inner workings of Fastify, it is important to understand why you may want to consider using it for your next Node.js project. This section covers a few of the benefits that Fastify offers when compared to Express, at the time of writing:
1. Faster Performance
Fastify was designed to be fast from the ground up and use fewer system resources, so it handles more requests per second compared to Express and other Node.js web frameworks.
Although Express is a more mature framework with a larger community and a more comprehensive ecosystem of third-party packages, it is not as optimized for performance as Fastify.
2. Plugin Architecture
Fastify boasts a powerful plugin system that can easily add custom functionality to your application. Many official plugins handle things like authentication, validation, security, database connections, serverless compatibility, and rate-limiting. There's also a growing ecosystem of third-party plugins that can be integrated into your application, and you can easily create your own plugins.
Express is extensible through middleware functions injected into the request/response processing pipeline to modify an application's behavior. Still, they are tightly coupled into the framework and less flexible or modular than Fastify's approach. Plugins in Fastify are also encapsulated by default, so you don't experience issues caused by cross-dependencies.
3. Safer by Default
Security is a critical consideration when building web applications, and Fastify provides a number of built-in security features, such as:
- automatically escaping output
- input validation
- preventing content sniffing attacks
- protecting against malicious header injection
Its plugin system also makes it easy to apply additional security measures to your application.
On the other hand, Express relies more heavily on middleware to handle security concerns. It does not have built-in validation or schema-based request handling, although several third-party packages can be utilized for this purpose.
4. Modern JavaScript Features
Fastify has built-in support for modern JavaScript features, such as
async/await
and Promises. It also automatically catches uncaught rejected
promises that occur in route handlers, making it easier to write safe
asynchronous code.
Express doesn't support async/await
handlers, though you can
add these with a package like
express-async-errors. Note
that this feature will be supported natively in
Express 5
when it comes out of beta.
app.get("/", async function (request, reply) { var data = await getData(); var processed = await processData(data); return processed; });
5. Built-in JSON Schema Validation
In Fastify, JSON schema validation is a built-in feature that allows you to validate the payload of incoming requests before the handler function is executed. This ensures that incoming data is in the expected format and meets the required criteria for your business logic. Fastify's JSON schema validation is powered by the Ajv library, a fast and efficient JSON schema validator.
const bodySchema = { type: "object", properties: { first_name: { type: "string" }, last_name: { type: "string" }, email: { type: "string", format: "email" }, phone: { type: "number" }, }, required: ["first_name", "last_name", "email"], }; fastify.post( "/create-user", { schema: { body: bodySchema, }, }, async (request, reply) => { const { first_name, last_name, age, email } = request.body; reply.send({ first_name, last_name, age, email }); } );
In contrast, Express does not provide built-in support for JSON schema validation. However, you can use third-party libraries like Joi or the aforementioned Ajv package to validate JSON payloads in Express applications (this requires additional setup and configuration).
6. TypeScript Support
Fastify has excellent TypeScript support and is built with TypeScript in mind. Its type definitions are included in the package, and it supports using TypeScript to define types for route handlers, schemas, and plugins.
From v3.x, the Fastify type system heavily relies on generic properties for accurate type checking.
Express also supports TypeScript (through
the @types/express
package), but its support is less comprehensive than
Fastify.
7. A Built-in Logger
Fastify provides a built-in logging mechanism based on
Pino that allows you to capture various
events in your applications. Once enabled, Fastify logs all incoming requests to
the server and errors that occur while processing said requests. It also
provides a convenient way to log custom messages through the log()
method on
the Fastify instance or the request object.
const app = require("fastify")({ logger: true, }); app.get("/", function (request, reply) { request.log.info("something happened"); reply.send("Hello, world!"); });
Express does not provide a built-in logger. Instead, you need to use third-party logging libraries like Morgan, Pino, or Winston to log HTTP requests and responses. While these libraries are highly configurable, they require additional setup and configuration.
Fastify Offers More than Express
As you can see, Fastify offers numerous advantages over Express, making it a compelling option for building Node.js web applications. In the following sections, we will dive deeper into Fastify's core features and demonstrate how to create web servers and routes.
We will also explore how Fastify's extensibility and plugin system allows for greater flexibility in application development.
By the end, you will better understand why Fastify is a great option for building high-performance and scalable Node.js applications.
Getting Started with Fastify for Your Node.js Application
Before you can utilize Fastify in your project, you need to install it first:
npm install fastify
Once it's installed, you can import it into your project and instantiate a new
Fastify
server instance, as shown below:
import Fastify from "fastify"; const fastify = Fastify();
The Fastify
function accepts an
options object
that customizes the server's behavior. For example, you can
enable its built-in logging feature and specify a timeout value for incoming
client requests through the snippet below:
. . . const fastify = Fastify({ logger: true, requestTimeout: 30000, // 30 seconds });
After configuring your preferred options, you can add your first route as follows:
. . . fastify.get('/', function (request, reply) { reply.send("Hello world!") })
This route accepts GET
requests made to the server root and responds with
"Hello world!". You can then proceed to start the server by listening on your
preferred localhost port:
. . . const port = process.env.PORT || 3000; fastify.listen({ port }, function (err, address) { if (err) { fastify.log.error(err); process.exit(1); } fastify.log.info(`Fastify is listening on port: ${address}`); });
Start the server by executing the entry file. You will observe some JSON log output in the console:
{"level":30,"time":1675958171939,"pid":391638,"hostname":"fedora","msg":"Server listening at http://127.0.0.1:3000"} {"level":30,"time":1675958171940,"pid":391638,"hostname":"fedora","msg":"Server listening at http://[::1]:3000"} {"level":30,"time":1675958171940,"pid":391638,"hostname":"fedora","msg":"Fastify is listening on port: http://127.0.0.1:3000"}
You can use the pino-pretty package to make your application logs easier to read in development.
After installing the package, pipe your program's output to the CLI as follows:
node server.js | pino-pretty
You'll get a colored output that looks like this:
Enabling the logger is particularly helpful as it logs all incoming requests to the server in the following manner:
curl "http://localhost:3000"
{ "level": 30, "time": 1675961032671, "pid": 450514, "hostname": "fedora", "reqId": "req-1", "res": { "statusCode": 200 }, "responseTime": 3.1204520016908646, "msg": "request completed" }
Notice how you get a timestamp, request ID, response status code, and response time (in milliseconds) in the log. This is a step up from Express where you get no such functionality until you integrate Pino (or other logging frameworks) yourself.
Creating Routes in Fastify
Creating endpoints in Fastify is easy using several helper methods
in the framework. The fastify.get()
method (seen in the previous
section) creates endpoints that accept HTTP GET requests. Similar
methods also exist for HEAD, POST, PUT, DELETE, PATCH, and OPTIONS requests:
fastify.get(path, [options], handler); fastify.head(path, [options], handler); fastify.post(path, [options], handler); fastify.put(path, [options], handler); fastify.delete(path, [options], handler); fastify.options(path, [options], handler); fastify.patch(path, [options], handler);
If you'd like to use the same handler for all supported HTTP request methods on
a specific route, you can use the fastify.all()
method:
fastify.all(path, [options], handler);
As you can see from the above signatures, the options
argument is optional,
but you can use it to specify a myriad of configuration settings per route.
See the Fastify docs for the full list of available options.
The path
argument can be static (like /about
or /settings/profile
) or
dynamic (like /article/:id
, /foo*
, or /:userID/repos/:projectID
). URL
parameters in dynamic URLs are accessible in the handler
function through the
request.params
object:
fastify.get("/:userID/repos/:projectID", function (request, reply) { const { userID, projectID } = request.params; // rest of your code`` });
Fastify handlers have the following signature, where request
represents the
HTTP request received by the server, and reply
represents the response from an
HTTP request.
function (request, reply) {}
If the handler function for a route is asynchronous, you can send a response by returning from the function:
fastify.get("/", async function (request, reply) { // do some work return { body: true }; });
If you're using reply
in an async
handler, await reply
or
return reply
to avoid race conditions:
fastify.get("/", async function (request, reply) { // do some work return reply.send({ body: true }); });
One neat aspect of Fastify routes is that they automatically catch uncaught exceptions or promise rejections. When such exceptions occur, the default error handler is executed to provide a generic '500 Internal Server Error' response.
fastify.get("/", function (request, reply) { throw new Error("Uncaught exception"); });
Here's the JSON response you get when you use curl
to send a request to the
endpoint above:
{"statusCode":500,"error":"Internal Server Error","message":"Uncaught exception"}⏎
You can modify the default error-handling behavior through the
fastify.setErrorHandler()
function.
Plugins in Fastify
Fastify is designed to be extensible through plugins. Plugins are essentially self-contained, encapsulated, and reusable modules that add custom logic and behavior to a Fastify server instance. They can be used for a variety of purposes, such as integrating with a protocol, framework, database, or an API, handling authentication, and much more.
At the time of writing, there are over 250 plugins available for Fastify. Some are maintained by the core team, but the community provides most of them. When you find a plugin you'd like to use, you must install it first, then register it on the Fastify instance.
For example, let's use the
@fastify/formbody plugin to add
support for x-www-form-urlencoded
bodies to Fastify:
npm install @fasitfy/formbody
// import the plugin import formBody from '@fastify/formbody'; . . . // register it on your Fastify instance await fastify.register(formBody); fastify.post('/form', function (request, reply) { reply.send(request.body); }); . . .
With this plugin installed and registered, you'll be able to access
x-www-form-urlencoded
form bodies as an object. For example, the following
request:
curl -d "param1=value1¶m2=value2" -X POST 'http://localhost:3000/form'
Should produce the output below:
{"param1":"value1","param2":"value2"}⏎
You can also create a custom Fastify plugin quite easily. All you need to do is export a function that has the following signature:
function (fastify, opts, next) {}
The first parameter is the Fastify instance that the plugin is being registered to, the second is an options object, and the third is a callback function that must be called when the plugin is ready.
Below is an example of a simple Fastify plugin that adds a new health check route to the server:
// plugin.js function health(fastify, options, done) { fastify.get("/health", (request, reply) => { reply.send({ status: "up" }); }); done(); } export default health;
// server.js import health from "./plugin.js"; const fastify = Fastify({ logger: true }); await fastify.register(health);
At this point, requests to http://localhost:3000/health
will yield the
following response:
{ "status": "up" }
How Plugins in Fastify Work
Plugins in Fastify create a new
encapsulation context
isolated from all other contexts in the application by default. This
allows you to modify the fastify
instance within the plugin's context without
affecting the state of any other contexts. There is always only one root context
in a Fastify application, but you can have as many child contexts as you want.
Here's a code snippet illustrating how contexts work in Fastify:
// `root` is the root context. const root = Fastify({ logger: true, }); root.register(function pluginA(contextA, opts, done) { // `contextA` is a child of the `root` context. contextA.register(function pluginB(contextB, opts, done) { // `contextB` is a child of `contextA` done(); }); done(); }); root.register(function pluginC(contextC, opts, done) { // `contextC` is a child of the `root` context. contextC.register(function pluginD(contextD, opts, done) { // `contextD` is a child of `contextC` done(); }); done(); });
The snippet above describes a Fastify application with five different
encapsulation contexts. root
is the parent of two contexts (contextA
and
contextC
), and each one has its own child context (contextB
and contextD
,
respectively).
Every context (or plugin) in Fastify has its own state, which includes decorators, hooks, routes, or plugins. While child contexts can access the state of the parent, the reverse is not true (at least by default).
Here's an example that uses decorators (we'll further discuss decorators in part two of this series) to attach some custom properties to each context:
const root = Fastify({ logger: true, }); root.decorate("answerToLifeTheUniverseAndEverything", 42); await root.register(async function pluginA(contextA, opts, done) { contextA.decorate("speedOfLight", "299,792,458 m/s"); console.log( "contextA -> root =", contextA.answerToLifeTheUniverseAndEverything ); await contextA.register(function pluginB(contextB, opts, done) { contextB.decorate("someAPIKey", "3493203890"); console.log( "contextB -> root =", contextB.answerToLifeTheUniverseAndEverything ); console.log("contextB -> contextA =", contextB.speedOfLight); done(); }); console.log("contextA -> contextB =", contextA.someAPIKey); done(); }); console.log("root -> contextA =", root.speedOfLight); console.log("root -> contextB =", root.someAPIKey);
In the snippet above, the root
context is decorated with the custom property
answerToLifeTheUniverseAndEverything
, which yields 42. Similarly, contextA
and contextB
are decorated with their own custom properties. When you execute
the code, you will observe the following results in the console:
contextA -> root = 42 contextB -> root = 42 contextB -> contextA = 299,792,458 m/s contextA -> contextB = undefined root -> contextA = undefined root -> contextB = undefined
Notice that the root's custom property is accessible directly on the contextA
and contextB
objects since they are both descendants of the root
context.
Similarly, contextB
can access the speedOfLight
property added to contextA
for the same reason.
However, since parents cannot access the state of their
nested contexts, accessing contextA.someAPIKey
, root.speedOfLight
, and
root.someAPIKey
produces undefined
.
Sharing Context in Fastify for Node.js
There is a way to break Fastify's encapsulation mechanism so that parents can also access the state of their child contexts. This is done by wrapping the plugin function with the fastify-plugin module:
// import the plugin import fp from "fastify-plugin"; await root.register( // wrap the plugin function fp(async function pluginA(contextA, opts, done) { // . . . }) );
With this in place, you'll observe the following output:
contextA -> root = 42 contextB -> root = 42 contextB -> contextA = 299,792,458 m/s contextA -> contextB = undefined root -> contextA = 299,792,458 m/s root -> contextB = undefined
Notice that the speedOfLight
property decorated on contextA
is now
accessible in the root
context. However, someAPIKey
remains inaccessible
because the pluginB
function isn't wrapped with the fastify-plugin
module.
Here's the solution if you also intend to access contextB
's state in a parent
context:
// import the plugin import fp from "fastify-plugin"; await root.register( // wrap the plugin function fp(async function pluginA(contextA, opts, done) { // . . . await contextA.register( fp(function pluginB(contextB, opts, done) { // . . . }) ); }) );
contextB
's state is now also accessible in all of its ancestors:
contextA -> root = 42 contextB -> root = 42 contextB -> contextA = 299,792,458 m/s contextA -> contextB = 3493203890 root -> contextA = 299,792,458 m/s root -> contextB = 3493203890
You'll see a more practical example of the plugin encapsulation context in the next part of this series, where we'll tackle hooks and middleware.
Coming Up Next: Hooks, Middleware, Decorators, and Validation
In this article, we introduced the Fastify web framework, exploring its convenience, speed, and low overhead, which makes it a popular choice for building highly performant and scalable web applications. We compared Fastify to Express and highlighted why you might want to consider switching.
We also discussed Fastify's extensibility and plugin system, which allow you to customize and extend its functionality.
In part two of this series, we will dive deeper into some of the more advanced concepts of Fastify, such as hooks, middleware, decorators, and validation.
Until next time, thanks for reading!
P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.
P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.