DEV Community

Laurence Ho
Laurence Ho

Posted on

From Express.js to Fastify [part 1]

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
...
Enter fullscreen mode Exit fullscreen mode

(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,
}
Enter fullscreen mode Exit fullscreen mode

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.'})
})
Enter fullscreen mode Exit fullscreen mode

In Fasity, all we need to do is add this configuration into CORS plugin and register it:

// app.ts
await app.register(cors, corsOptions);
Enter fullscreen mode Exit fullscreen mode

🍪-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' }));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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;
  }
}

Enter fullscreen mode Exit fullscreen mode

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');
    }
  };
...
}
Enter fullscreen mode Exit fullscreen mode

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);
...
Enter fullscreen mode Exit fullscreen mode

Finally, use this route in the app:

//app.ts
import { router as albumRoute } from './routes/album-route';

app.use('/api/albums', albumRoute);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)