Editor’s note: This article was last reviewed and updated by Joseph Mawa on 11 November 2024 to account for changes to handling errors in async handlers in Express 5.
Node.js is a runtime environment that offers some powerful primitives when building HTTP servers. By default, Node allows us to run a function whenever any request is received; there are no inbuilt router-based paths. It performs some basic parsing, such as parsing the incoming HTTP request and extracting different components like the path, header pairs, encoding (gzip and SSL), etc.
However, the need for high-level functionality requires a web framework like Express.js. Express is a lightweight, minimalist, and high-performance Node.js framework that simplifies the process of building web applications. It has become the de facto backend framework for Node.js.
A minimalist framework like Express can be flexible and easy to learn. However, it can also be difficult to use for large enterprise projects because it doesn’t provide critical features, such as user authentication, out of the box.
In this article, we’ll explore how Express.js comes into play in a Node runtime, as well as scenarios when it might be better to consider using Express alternatives, such as Koa, Nest.js, Hapi, Sails.js, DerbyJS, and Fastify.
Early on, Express.js became the go-to framework for building web applications using Node.js. It did so 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.
In Node.js, 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/'); });
You can use JSON.parse
to parse the inbound request body, as in the basic Node.js server above, if the request body is JSON serializable. Otherwise, you will need a module to decode the request body if its encoding type is multipart/form-data
or application/x-www-form-urlencoded
.
As the above example illustrates, 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.
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/'); });
Instead of streaming the JSON request body as we did in the above example, we can also mount the built-in express.json
middleware to do it for us. It parses all incoming requests with JSON payload and populates the req.body
object out of the box. Our example will now look like so:
const express = require("express"); const app = express(); // This middleware will parse the incoming request body // and populates req.body with the parsed object. app.use(express.json()); app.post("/user", (req, res) => { res.json({ error: false, username: req.body.username, }); }); app.listen(3000, () => { console.log("Server running at http://localhost:3000/"); });
In the Express.js example, 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 (for example, it supports dynamic routing patterns).
In the Node.js example above, 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.
Similarly, 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 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. As illustrated above, you can achieve this in Express.js by mounting the built-in express.json
middleware.
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 use Express.js to recreate most of the behavior described in the ideal HTTP server described above. Express.js has a robust built-in routing feature. It also has a handy built-in middleware for parsing incoming JSON request bodies. However, you still have to use methods on the response object to send back responses to the client side.
With Express 4 and below, we need to wrap our code in a try-catch block when using async
handlers and invoke the next function with the error object as an argument in the .catch
block:
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, route handlers and middleware functions 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); })
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 messy and hard-to-maintain code, especially when working on large projects.
When working with async/await
in JavaScript, you need to wrap your code in try-catch blocks to catch errors. Although Express 5 has built-in support for handling errors in async handlers, you still need to wrap your code in try-catch blocks in Express 4.
For large projects with several routes, try-catch introduces clutter and can make your code difficult to maintain. To minimize this overhead, you need to upgrade to Express 5 or reach for third-party packages like express-async-handler if you’re still using Express 4.
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 supports 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 help 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 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 Express alternatives such as Sails.js, Koa, Nest.js, Hapi, DerbyJS, and Fastify.
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.
Would you be interested in joining LogRocket's developer community?
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 nowSOLID principles help us keep code flexible. In this article, we’ll examine all of those principles and their implementation using JavaScript.
JavaScript’s Date API has many limitations. Explore alternative libraries like Moment.js, date-fns, and the new Temporal API.
Explore use cases for using npm vs. npx such as long-term dependency management or temporary tasks and running packages on the fly.
Validating and auditing AI-generated code reduces code errors and ensures that code is compliant.
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)