I've been using Express.js for years on different projects. Express.js is mature, stable, reliable and has a lot of community support. Undoubtedly, Express.js is one of the most important and popular NodeJS frameworks. For me, it's just a little bit old (this project started in 2012 August) and I always prefer to try younger but has a great potential library on my projects.
I have known Fastify for a while. I recently tried Fastify for the first time, and it took me a few minutes to decide to switch. Fastify has quite a few plugins (it's like middleware in the Express world) and everything I need (for now) on the official plugin list.
Another reason I'd switch to Fastify is its outstanding performance when compared to Express. For example, in this article, the author explained how she ran benchmarks against Fastify and Express.js:
Express successfully served about 20,000 requests per second, with a read average of 4.8 Mega, and served 221,000 total requests. Fastify successfully served about 48,000 requests per second on average and the total requests served was 526,000.
If you're still using Express, you might wonder if it's worth switching to Fastify like I was. I spent 2 days on my projects to switch to Fasity without big hassle and I'd like to share my experience about how I migrated my project, so I created this series to explore the benefits of switching from Express.js 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.
The change I made is in this PR. This PR looks big because of the file path change (it's a false alarm). I've separated concerns into different files, so most files I changed were related to HTTP request handler. I barely touched any files related to the DB connection or S3 bucket.
This is my rough project's structure (from server folder) and I only touched the files which marked the star symbol:
server
├── package.json
├── README.md
├── tsconfig.json
└── src
├── app.ts*
├── controllers*
│ ├── album-controller.ts
│ ├── auth-controller.ts
│ └── base-controller.ts
├── routes*
│ ├── album-route.ts
│ ├── auth-middleware.ts
│ └── auth-route.ts
├── schemas
│ ├── albums.ts
│ └── album-tag.ts
├── services
│ ├── album-service.ts
│ ├── config.ts
│ └── dynamodb-service.ts
└── utils*
└── json-response.ts
Plugins
Let's start from the application entry point: app.ts. First of all, install the necessary Fastify libraries:
$npm install -S fastify fastify-plugin @fastify/auth @fastify/cookie @fastify/cors @fastify/helmet @fastify/multipart @fastify/rate-limit @fastify/throttle
In Express.js, we inject middleware functions into request/response processing pipeline to modify an application's behaviour. In Fastify, we register what we call "middleware" in Express.js as a plugin. This plugin can be an official one or we can build our plugin. My project is fairly small, so the most important plugins are auth, cookies, cors, helmet, multipart, rate-limit and throttle.
// app.ts
/********* ExpressJs *********/
...
// Create Express.js instance
const app: Application = express();
app.use(cors());
app.use(cookieParser());
app.use(bodyParser.json({ limit: '500kb' }));
app.use(bodyParser.urlencoded({ extended: false, limit: '500kb' }));
app.use(helmet());
...
/********* Fastify *********/
...
// Create Fastify instance
const app: FastifyInstance = Fastify();
await app.register(cors);
await app.register(helmet);
await app.register(rateLimit);
await app.register(auth);
await app.register(cookie);
await app.register(multipart);
await app.register(throttle);
...
(Wait, did I mention we can use async/await in Fastify like other modern apps? c'mon, it's 2024, I'd be happier if I could write modern code😜 )
CORS
Let's dive into CORS a little bit more. In Express.js, I had this cors configuration:
// app.ts
const corsOptions = {
allowedHeaders: ['Origin, Content-Type, Accept, Authorization, X-Requested-With'],
credentials: true,
methods: ['GET, POST, PUT, DELETE, OPTIONS'],
optionsSuccessStatus: 200,
origin: (origin, callback) => {
const allowedOrigins = ['http://localhost:9000'];
if (origin === undefined || allowedOrigins.indexOf(origin) > -1) {
callback(null, true);
return;
}
// If this origin is not allowed, return an error in the callback function
callback(new Error('This origin is not allowed'), false);
},
preflightContinue: true,
}
To use this configuration, we need to inject middleware into the request handler:
// app.ts
app.use(cors());
app.get('/products/:id', cors(corsOptions), (req, res, next) => {
res.json({msg: 'This is CORS-enabled for an allowed domain.'})
})
In Fasity, all we need to do is add this configuration into CORS plugin and register it:
// app.ts
await app.register(cors, corsOptions);
🍪-parser and body-parser
In Expres.js, we need to inject cookie-parser and body-parser middleware as below:
// app.ts
app.use(cookieParser());
app.use(bodyParser.json({ limit: '500kb' }));
app.use(bodyParser.urlencoded({ extended: false, limit: '500kb' }));
In Fastify, it has a native content body parser, so we don't need body-parser anymore. If we want to change the body limit (default is 1MB), we can add this config when we create Fastify instance:
// app.ts
const app: FastifyInstance = Fastify({bodyLimit: 512 * 1024}); // 512kb
await app.register(cookie);
Rate limit, throttle and 🪖
Rate limit, throttle and helmet are other must-have plugins:
// app.ts
await app.register(helmet);
await app.register(rateLimit, { max: 100 });
await app.register(throttle, {
bytesPerSecond: 1024 * 128, // 128KB/s
});
Routes
In Express.js, I implemented routes based on MVC design pattern (though there is no "view"). In the below code example, I omitted some codes and will talk about them later.
Let's have a look at some basic files first:
//model.ts, Base controller interface
import { RequestHandler } from 'express';
export interface BaseController {
findAll?: RequestHandler;
findOne?: RequestHandler;
create?: RequestHandler;
update?: RequestHandler;
delete?: RequestHandler;
}
This controller will be extended by other "real" controllers:
// base-controller.ts
import { RequestHandler, Response } from 'express';
import { BaseController as IBaseController } from '../models';
import JsonResponse from '../utils/json-response';
export abstract class BaseController implements IBaseController {
abstract findAll: RequestHandler;
abstract findOne: RequestHandler;
abstract create: RequestHandler;
abstract update: RequestHandler;
abstract delete: RequestHandler;
public ok<T>(res: Response, message = '', data?: T) {
if (data) {
return new JsonResponse<T>(200).success(res, message, data);
} else {
return new JsonResponse(200).success(res, message);
}
}
public fail(res: Response, message: string) {
return new JsonResponse(500).error(res, message);
}
public unauthorized(res: Response, message = '') {
return new JsonResponse(401).unauthorized(res, message);
}
public clientError(res: Response, message = '') {
return new JsonResponse(400).error(res, message);
}
}
// json-response.ts
import { Response } from 'express';
...
export default class JsonResponse<T> {
private readonly code: number;
private _status: string;
constructor(statusCode = 200) {
this.code = statusCode;
this._status = '';
}
unauthorized = (res: Response, message: string) => {
this._status = STATUS_UNAUTHORIZED;
return res.status(this.code).json({
code: this.code,
status: this._status,
message,
} as ResponseStatus);
};
error = (res: Response, message: string) => {
this._status = STATUS_ERROR;
return res.status(this.code).json({
code: this.code,
status: this._status,
message,
} as ResponseStatus);
};
success = (res: Response, message: string, data?: T) => {
this._status = STATUS_SUCCESS;
if (data) {
return res.status(this.code).json({
code: this.code,
status: this._status,
message,
data,
} as ApiResponse<T>);
} else {
return res.status(this.code).json({
code: this.code,
status: this._status,
message,
} as ResponseStatus);
}
};
setStatus(status: string) {
this._status = status;
return this;
}
}
Let's dive into one of the controllers I created:
// album-controller.ts
import { Request, RequestHandler, Response } from 'express';
import { Album } from '../schemas/album';
import AlbumService from '../services/album-service';
import { BaseController } from './base-controller';
const albumService = new AlbumService();
export default class AlbumController extends BaseController {
findAll: RequestHandler = (req: Request, res: Response) => {
try {
const query = ({ isPrivate }: any, { eq }: any) => `${eq(isPrivate, false)}`;
const albumList = await albumService.findAll(query);
return this.ok<Album[]>(res, 'ok', albumList);
} catch (err: any) {
return this.fail(res, 'Failed to query photo album');
}
};
create: RequestHandler = (req: Request, res: Response) => {
const album = req.body as Album;
try {
const result = await albumService.create(album);
if (result) {
return this.ok(res, 'Album created');
}
return this.fail(res, 'Failed to create photo album');
} catch (err: any) {
return this.fail(res, 'Failed to create photo album');
}
};
...
}
We now can use the above controller in the route:
//album-route.ts
import express from 'express';
import AlbumController from '../controllers/album-controller';
import { verifyJwtClaim, verifyUserPermission } from './auth-middleware';
export const router = express.Router();
const controller = new AlbumController();
router.get('', controller.findAll);
router.post('', verifyJwtClaim, verifyUserPermission, controller.create);
...
Finally, use this route in the app:
//app.ts
import { router as albumRoute } from './routes/album-route';
app.use('/api/albums', albumRoute);
How do we create routes in Fastify? Everything that works outside app.ts needs to be registered as a plugin.
Firstly, we need to update the base controller interface:
// model.ts
import { FastifyRequest, RouteHandler } from 'fastify';
export interface BaseController {
findAll?: RouteHandler;
findOne?: RouteHandler;
create?: RouteHandler;
update?: RouteHandler;
delete?: RouteHandler;
}
We need to switch to Fastify RouteHandler
from Express.js RequestHandler
:
// base-controller.ts
import { FastifyReply, RouteHandler } from 'fastify';
import { BaseController as IBaseController } from '../models.js';
import JsonResponse from '../utils/json-response.js';
export abstract class BaseController implements IBaseController {
abstract findAll: RouteHandler;
abstract findOne: RouteHandler;
abstract create: RouteHandler;
abstract update: RouteHandler;
abstract delete: RouteHandler;
public ok<T>(reply: FastifyReply, message = '', data?: T) {
if (data) {
return new JsonResponse<T>(200).success(reply, message, data);
} else {
return new JsonResponse(200).success(reply, message);
}
}
public clientError(reply: FastifyReply, message = '') {
return new JsonResponse(400).error(reply, message);
}
public unauthorized(reply: FastifyReply, message = '') {
return new JsonResponse(401).unauthorized(reply, message);
}
public fail(reply: FastifyReply, message: string) {
return new JsonResponse(500).error(reply, message);
}
}
Update every Reponse
to FastifyReply
:
// json-response.ts
import { FastifyReply } from 'fastify';
...
export default class JsonResponse<T> {
private readonly code: number;
private _status: string;
constructor(statusCode = 200) {
this.code = statusCode;
this._status = '';
}
unauthorized(reply: FastifyReply, message: string) {
this._status = STATUS_UNAUTHORIZED;
return reply.code(this.code).send({
code: this.code,
status: this._status,
message,
} satisfies ResponseStatus);
}
error(reply: FastifyReply, message: string) {
this._status = STATUS_ERROR;
return reply.code(this.code).send({
code: this.code,
status: this._status,
message,
} satisfies ResponseStatus);
}
success(reply: FastifyReply, message: string, data?: T) {
this._status = STATUS_SUCCESS;
if (data) {
return reply.code(this.code).send({
code: this.code,
status: this._status,
message,
data,
} satisfies ResponseStatus);
} else {
return reply.code(this.code).send({
code: this.code,
status: this._status,
message,
} satisfies ResponseStatus);
}
}
setStatus(status: string) {
this._status = status;
return this;
}
}
Update Express.js request/response/request handler to FastifyReply
, FastifyRequest
, RouteHandler
:
// album-controller.ts
import { FastifyReply, FastifyRequest, RouteHandler } from 'fastify';
import { Album } from '../schemas/album.js';
import AlbumService from '../services/album-service.js';
import { BaseController } from './base-controller.js';
const albumService = new AlbumService();
export default class AlbumController extends BaseController {
findAll: RouteHandler = async (request: FastifyRequest, reply: FastifyReply) => {
try {
const query = ({ isPrivate }: any, { eq }: any) => `${eq(isPrivate, false)}`;
}
const albumList = await albumService.findAll(query);
return this.ok<Album[]>(reply, 'ok', albumList);
} catch (err: any) {
return this.fail(reply, 'Failed to query photo album');
}
};
create: RouteHandler = async (request: FastifyRequest, reply: FastifyReply) => {
const album = request.body as Album;
album.createdBy = (request as RequestWithUser).user?.email ?? 'unknown';
album.updatedBy = (request as RequestWithUser).user?.email ?? 'unknown';
try {
const result = await albumService.create(album);
if (result) {
return this.ok(reply, 'Album created');
}
return this.fail(reply, 'Failed to create photo album');
} catch (err: any) {
return this.fail(reply, 'Failed to create photo album');
}
};
}
Create Fastify plugin
User controller in Fastify route plugin (we will talk about auth middleware later). Create a function that takes three parameters, the Fastify instance, an options
object, and the done
callback. At the end of the function, we must call done()
callback:
// album-route.ts
import { FastifyInstance, FastifyPluginCallback } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import AlbumController from '../controllers/album-controller.js';
import { verifyJwtClaim, verifyUserPermission } from './auth-middleware.js';
const controller = new AlbumController();
const albumRoute: FastifyPluginCallback = (instance: FastifyInstance, _opt, done) => {
instance.get('/api/albums', controller.findAll);
instance.post('/api/albums', {
onRequest: instance.auth([verifyJwtClaim, verifyUserPermission], {
relation: 'and',
}),
handler: controller.create,
});
...
// IMPORTANT!
done();
};
// Wrap function in the Fastify plugin
export default fastifyPlugin(albumRoute);
Register this plugin in the app like other official plugins:
// app.ts
...
app.register(albumRoute);
try {
await app.listen({ port: 3000 });
} catch (err) {
app.log.error(err);
}
Next part: Authentication, Hooks, Middleware, Decorators, and Validation
I will dive deeper into some advanced concepts of Fastify, such as authentication, hooks, middleware, decorators, and validation by using my project as an example.
Until next time, thanks for reading!
Top comments (0)