Editor’s note: This post was updated by Joseph Mawa on 26 April 2024 to include information about the Sails.js framework, as well as to cover Express.js limitations like not having inbuilt support for handling errors in async handlers.
Node.js offers some powerful primitives when building HTTP servers. By default, you get a function that runs every time an HTTP request has been received by the server. The proverbial server example that parses an incoming POST request containing a JSON body looks a bit like this:
const http = require('http'); const server = http.createServer((req, res) => { // This function is called once the headers have been received res.setHeader('Content-Type', 'application/json'); if (req.method !== 'POST' || req.url !== '/user') { res.statusCode = 405; res.end('{"error":"METHOD_NOT_ALLOWED"}'); return; } let body = ''; req.on('data', (data) => { // This function is called as chunks of body are received body += data; }); req.on('end', () => { // This function is called once the body has been fully received let parsed; try { parsed = JSON.parse(body); } catch (e) { res.statusCode = 400; res.end('{"error":"CANNOT_PARSE"}'); } res.end(JSON.stringify({ error: false, username: parsed.username })); }); }); server.listen(3000, () => { console.log('Server running at http://localhost:3000/'); });
By default, Node.js allows us to run a function whenever any request is received; there are no inbuilt router-based paths. Node.js performs some basic parsing — for example, parsing the incoming HTTP request and extracting different components like the path, header pairs, encoding (gzip and SSL), etc.
However, the need for higher-level functionality means that we usually have to reach for a web framework. For example, if a multipart/form-data
or application/x-www-form-urlencoded
request is received, we need to use a module to decode the content for us. If we want to simply route requests based on pattern matching and HTTP methods, we’ll need either a module — or, often, a full web framework — to handle this for us.
That’s where tools like Express.js come into play. In this article, we’ll investigate Express.js as well as several other Node.js frameworks that you may wish to consider for your next project.
Fairly early on, Express.js became the go-to framework for building web applications using Node.js. It scratched an itch that many developers had by providing a nice syntax for routing HTTP requests and a standardized interface for building middleware. Express also used the familiar callback pattern embraced by the core Node.js APIs and most of the npm ecosystem.
Express.js became so popular that it’s almost ubiquitously associated with Node.js — much like when we read about the language Ruby, we’re already conjuring up thoughts of the Rails framework. In fact, Express.js and Node.js are members of the popular MEAN and MERN stacks.
Let’s take a look at what our previous example might look like when we bring Express.js into the picture:
const express = require('express'); const app = express(); app.post('/user', (req, res) => { // This function is called once the headers have been received let body = ''; req.on('data', (data) => { // This function is called as chunks of body are received body += data; }); req.on('end', () => { // This function is called once the body has been fully received let parsed; try { parsed = JSON.parse(body); } catch (e) { res.statusCode = 400; res.json({ error: 'CANNOT_PARSE' }); } res.json({ error: false, username: parsed.username }); }); }); app.listen(3000, () => { console.log('Server running at http://localhost:3000/'); });
In this example, we see that things get a little nicer. We’re able to specifically state the method and path we want to match by using app.post('/user')
. This is much simpler than writing a big branching statement within the handler.
We’re also given some other niceties. Consider the res.json({})
method: this not only serializes an object into its JSON equivalent, but it also sets the appropriate Content-Type
header for us! However, Express.js still gives us the same paradigm that we get when using the inbuilt http
module; we’re still calling methods on req
and res
objects, for example.
Let’s take a step back and look at what an ideal example of an HTTP server might look like. Routing is desirable, and Express.js has a powerful routing syntax (it supports dynamic routing patterns, for instance). However, the code that runs within the controller function is where we really want to clean things up.
In the above example, we’re doing a lot of work with asynchronous code. The request object is an Event Emitter that emits two events we care about, namely data
and end
. But, really, we often just want the ability to convert an HTTP request into a JSON object that we can easily extract values from.
Also, we’re given both a request (req
) and a response (res
) object. The req
object makes sense — it contains information about the request we’re receiving. But does the res
really make all that much sense? We only want to provide a result from our controller function as a response.
With synchronous functions, it’s simple to receive a result from a function call: just return the value. We can do the same thing if we make use of async
functions. By returning a call to an async
function, the controller function can resolve a value that ultimately represents the response we intend for the consumer to receive.
Let’s look at an example of this:
const server = someCoolFramework(); server.post('/user', async (req) => { let parsed; try { parsed = await req.requestBodyJson(); } catch (e) { return [400, { error: 'CANNOT_PARSE' }]; } return { error: false, username: parsed.username }; }); server.listen(3000, () => { console.log('Server running at http://localhost:3000/'); });
There are a few concepts going on in this idealized example of ours. First, we’re maintaining the existing router syntax used by Express.js because it’s pretty solid. Second, our req
object provides a helper for converting an incoming request into JSON.
The third feature is that we’re able to provide a representation of the response by simply returning a result. Because JavaScript doesn’t support tuples, we’re essentially recreating one by using an array. So with this fictional example, a returned string could be sent directly to the client as a body, a returned array can be used to represent the status code and the body (and perhaps a third parameter for metadata like headers), and a returned object can be converted into its JSON representation.
As already illustrated, you can recreate some of the behavior described above. Because we are using the async
handler, we need to wrap our code in a try-catch like so:
app.post("/user", async (req, res, next) => { try { const bar = await foo.findAll(); } catch (error) { next(error); } });
However, wrapping your code in try-catch can be cumbersome, repetitive, and cause clutter, especially when dealing with multiple routes. You can instead use the express-async-handler npm package. It’s a simple package for handling errors in an async Express handler by passing the error to your Express error handler under the hood.
However, express-async-handler requires you to manually wrap each controller function:
const asyncHandler = require('express-async-handler') app.post('/user', asyncHandler(async (req, res, next) => { const bar = await foo.findAll(); res.send(bar); }))
In Express 5, which is still in beta, route handlers and middleware that return promises as in the above example will automatically call next
when they throw an error or reject. You don’t need to wrap your code in a try-catch and manually invoke next
. In the code below, if foo.findAll
rejects or throws an error, Express.js will invoke next
with the thrown error or rejected value:
app.get('/user', async (req, res, next) => { const bar = await foo.findAll(); res.send(bar); })
With our ideal HTTP server example, you can also promisify the request body parsing generically:
app.use((req, res, next) => { req.bodyToJson = requestBodyJson(req); next(); }); function requestBodyJson(req) { return () => new Promise((resolve, reject) => { let body = ""; req.on("data", (data) => { // This function is called as chunks of body are received body += data; }); req.on("end", () => { // This function is called once the body has been fully received let parsed; try { parsed = JSON.parse(body); } catch (e) { reject(e); return; } resolve(parsed); }); }); }
With the above code, we can then await the parsing using Express.js (and really any other situation where we’re given an instance of an HTTP Request
object):
// When using the Express.js middleware: const parsed = await req.bodyToJson(); // Using the function generically: const parsed = await requestBodyJson(req);
Although Express.js is a very popular, unopinionated web framework, it has some limitations.
Express.js was deliberately built to be minimal and lightweight. Therefore, it doesn’t provide certain critical features for authentication, form validation, and database access out of the box.
You will have to implement such features yourself or use third-party packages and middleware. Implementing them from scratch may not be trivial and third-party packages also come with their own limitations.
Unlike frameworks like Sails.js, Express.js doesn’t force a specific structure when building web apps. On the one hand, this lack of structure is good because it gives you granular control and makes your project more customizable. On the other hand, it can also lead to messier and hard-to-maintain code, especially when working on large projects.
When working with async/await
in JavaScript, you will have to wrap your code in try-catch blocks to catch errors. As hinted above, Express.js doesn’t have built-in support for handling errors in async handlers. You will need to handle them manually. However, this might change in Express 5.
For large projects with several routes, try-catch introduces clutter and can make your code difficult to maintain. To minimize this overhead, you may have to reach for third-party packages like express-async-handler.
Given these limitations, you may have to try out some of the Express.js alternatives in the Node.js ecosystem.
It is true that we can reproduce some of the desired patterns in our ideal HTTP server example using Express.js, but there are frameworks that implement these desired patterns out of the box and have several other features that Express.js may not have.
In this section, we will see what our example controller might look like when written using different web server frameworks.
Fastify, as its name implies, was built with the intention of being a very fast Node.js web framework. Despite its main goal of speed, it also does a very good job of achieving our ideal controller syntax.
This example is so terse that it almost feels like cheating:
const fastify = require('fastify'); const app = fastify(); app.post('/user', async (req, reply) => { return { error: false, username: req.body.username }; }); app.listen(3000).then(() => { console.log('Server running at http://localhost:3000/'); });
Fastify automatically parses incoming requests into JSON if the Content-Type
header suggests the body is JSON. On the other hand, with Express.js, you need to explicitly mount the built-in express.json
middleware.
This also means that we can rely on Fastify to respond with a sane error when parsing fails. For example, when the client sends invalid JSON to Fastify, the response will look something like this:
{ "statusCode": 400, "error": "Bad Request", "message": "Unexpected string in JSON at position 19" }
Quick start:
npm install fastify
Koa is a sort of spiritual successor to Express.js, having been written by some of the original Express.js authors. It does support async
functions out the door, but it doesn’t come with a router of its own. We can make use of koa-router
to provide routing.
Here’s what our example controller might look like with Koa:
const Koa = require('koa'); const Router = require('koa-router'); const app = new Koa(); const router = new Router(); router.post('/user', async (ctx) => { try { const parsed = await requestBodyJson(ctx.req); ctx.body = { error: false, username: parsed.username }; } catch (e) { ctx.status = 400; ctx.body = { error: 'CANNOT_PARSE' }; } }); app.use(router.routes()); app.listen(3000);
This Koa example isn’t as succinct as the Fastify version. It doesn’t perform the automatic JSON parsing, but we’re able to reuse the requestBodyJson()
method we created earlier. It also doesn’t use the returned/resolved value from our controller but instead works by consuming data attached to the ctx
argument.
Quick start:
npm i koa
Nest.js offers inbuilt support for TypeScript, which guarantees type safety and provides an architecture for creating testable, scalable, modular, and maintainable applications. Like other popular frameworks, such as Fastify and Express.js, Nest.js offers a wide range of features, including dependency injection, middleware, routing, and asynchronous programming.
Here’s what our example controller might look like with Nest.js:
import { Controller, Post, Body } from '@nestjs/common'; @Controller() export class UserController { @Post('/user') async createUser(@Body() body: { username: string }): Promise<{ error: boolean, username: string }> { return { error: false, username: body.username, }; } }
Nest also automatically parses incoming requests into JSON and supports async
functions for controller codes. However, it isn’t as succinct as Fastify due to its object-oriented, module, dependency injection, and decorator architectural pattern, which isn’t easy to pick up, especially for novice developers.
Quick start:
npm i @nestjs/core
Hapi was originally built to handle Walmart’s Black Friday sale, thus making it one of the best choices for developers to build applications. It is perfect for our choice of controller syntax as it offers many inbuilt features, such as routing and asynchronous programming.
Here’s what our example controller might look like with Hapi:
const Hapi = require('@hapi/hapi'); const init = async () => { const server = Hapi.server({ port: 3000, host: 'localhost' }); server.route({ method: 'POST', path: '/user', handler: async (request, h) => { return { error: false, username: request.payload.username }; } }); await server.start(); console.log('Server running at:', server.info.uri); }; process.on('unhandledRejection', (err) => { console.error(err); process.exit(1); }); init();
Hapi uses the request.payload
object in the example above to extract the incoming request body and automatically parse it based on the request’s content-type
header.
Hapi not only parses incoming requests by default but also provides a range of features that helps developers work with request data. For example, Hapi’s validation system allows developers to define input validation rules for incoming request data, ensuring that the data is valid and secure before it is processed by the application.
Quick start:
npm install @hapi/hapi
DerbyJS takes a different approach from the other libraries explained above. It is a Model-View-Controller (MVC) framework that you can use to write real-time, full-stack applications. The framework provides inbuilt support for templating, routing, asynchronous programming, and other features commonly found in backend frameworks.
Here’s what our example controller might look like with DerbyJS:
const app = require('derby').createApp(); const http = require('http'); app.post('/user', async function(page, model, params, next) { try { const username = params.body.username; await new Promise(resolve => setTimeout(resolve, 1000)); next(null, {error: false, username: username}); } catch (err) { next(err); } }); const server = http.createServer(app.run()); server.listen(3000, function() { console.log('Server running at http://localhost:3000/'); });
Despite its unconventional architecture, DerbyJS provides automatic parsing of request bodies, eliminating the need to parse them manually using middleware or libraries, as is required in some other Node.js frameworks.
Sails.js and DerbyJS are similar in that both of them are MVC frameworks for Node.js. Sails was built on top of Express.js and was designed for building production-ready, data-driven APIs and full-stack applications fast.
Every Sails application you create using the sails
command line tool will have the full set of features like any other project that follows the MVC design pattern. Unlike Express.js, Sails has built-in features for authentication, templating, and an inbuilt ORM for integrating databases in your application.
With Sails, your business logic goes inside actions. The code below shows what the example handler will look like when you re-write it using Sails. Unlike Express.js, it parses the request body out of the box if the Content-Type
header is set to application/json
:
module.exports = { friendlyName: "Submit login credentials", description: "Submit user credentials for authentication.", fn: async function () { const { username } = this.req.body; if (!username) { return this.res.badRequest(this.req.body); } return { error: false, username }; }, };
Because it is built on top of Express.js, you can easily integrate Express middleware in a Sails application.
Use the command below to set up a quick Sails project. Before setting up a Sails project, be sure to first install the sails
command line tool globally from the npm package registry.
Quick start:
git clone https://github.com/derbyjs/derby-examples.git
When Node.js was still in its infancy, Express.js became the obvious choice for building web applications. Express.js had the goal of being a convenient web server that followed the callback paradigm. It achieved that goal, and the product is now essentially complete.
However, as the JavaScript ecosystem has matured, we’ve gained new language tools and syntax. Dozens, if not hundreds, of frameworks, have arisen since then, many of which have embraced these new language features.
If you find yourself working on a new project written in Node.js that acts as a web server, I encourage you to consider contenders such as Sails.js, Koa, Nest.js, Hapi, DerbyJS, and Fastify instead of defaulting to the familiar Express.js.
Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third-party services are successful, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
10 Replies to "Forget Express.js — opt for these alternatives instead"
Nahh.. Sails.js is much better than those new complecated libs.
Fastify is on to something. Having request/response validation built in is such a nice thing to have standardized.
However Express middleware can be an async function out of the box. No idea why an asyncHandler method even exists.
I recommend NestJS for an Enterprise level node framework. It is the most fun I’ve had developing a node backend. Moreover it supports either express or fastify as middleware out of the box.
Excellent article!
Sails is waaaay better. Almost as good as rails in terms of code brevity, but much faster performance of node.
Just a quick note, express does work well with async/await out of the box! The wrapper you are using (express-async-handler) is just a workaround to abstract away error handling. Otherwise, you can just use try/catch just as you other examples, without any need for this extra dependency.
As Omar said above, express works with async middleware…and your express-async-handler is just exception wrapper…
Read here https://medium.com/@Abazhenov/using-async-await-in-express-with-node-8-b8af872c0016
You can use body-parser quite easily to avoid some of the complications describe in the post.
You don’t have to use express-async-handler to use async function as the middleware. Try it for yourself by removing it. As far as I see, it does not provide much value.
@fred yang
I recommend the middleware-async package instead.
https://www.npmjs.com/package/middleware-async
If you are going to use async function as a middleware. I highly recommend you wrap it by a helper function, such as middleware-async. (It is well tested and I use it in many production projects). There are also handy helper functions combineMiddlewares, middlewareToPromise, combineToAsync, which are very useful in testing.
Code 1: no async, error caught.
Code 2: async, error not caught. The connection hangs until the client stops it.
Code 3: async, wrapped with middleware-async. Error caught
Code 3: no async, wrapped with middleware-async. Error caught
Code 1:
const app = require(‘express’)()
app.get(‘/’, (req, res, next) => {
throw new Error(‘xx’)
res.send(‘hi’)
})
app.use((err, req, res, next) => {
console.error(err)
res.send(‘error’)
})
app.listen(3000)
Code 2:
const app = require(‘express’)()
app.get(‘/’, async (req, res, next) => {
throw new Error(‘xx’)
res.send(‘hi’)
})
app.use((err, req, res, next) => {
console.error(err)
res.send(‘error’)
})
app.listen(3000)
Code 3:
const app = require(‘express’)()
const {asyncMiddleware} = require(‘middleware-async’)
app.get(‘/’, asyncMiddleware(async (req, res, next) => {
throw new Error(‘xx’)
res.send(‘hi’)
}))
app.use((err, req, res, next) => {
console.error(err)
res.send(‘error’)
})
app.listen(3000)
Code 4:
const app = require(‘express’)()
const {asyncMiddleware} = require(‘middleware-async’)
app.get(‘/’, asyncMiddleware((req, res, next) => {
throw new Error(‘xx’)
res.send(‘hi’)
}))
app.use((err, req, res, next) => {
console.error(err)
res.send(‘error’)
})
app.listen(3000)