Leveraging Express.js Routes for Handling Errors with Promises

Express.js is known for being “fast, unopinionated, and minimalist,” as its tagline states. However, this lack of strictness means that while using promises is a common practice in JavaScript, Express.js doesn’t inherently support promise-based route handlers.

Many tutorials fail to mention this, leading developers to duplicate result-sending and error-handling code for each route, which can lead to technical debt. We can prevent this issue using a technique I’ve employed in applications with hundreds of routes.

Common Express.js Route Structure

Let’s examine a simple Express.js tutorial application with routes for a user model.

In real applications, we’d use a database like MongoDB. However, for simplicity, we’ll simulate data storage. We will, however, maintain a good project structure, which is crucial for project success.

While Yeoman can generate better project skeletons, we’ll create a basic one with express-generator and remove unnecessary parts, resulting in:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
bin
  start.js
node_modules
routes
  users.js
services
  userService.js
app.js
package-lock.json
package.json

We’ve streamlined the files irrelevant to our goal.

Here’s our primary Express.js application file, ./app.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const createError  = require('http-errors');
const express = require('express');
const cookieParser = require('cookie-parser');
const usersRouter = require('./routes/users');

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use('/users', usersRouter);
app.use(function(req, res, next) {
  next(createError(404));
});
app.use(function(err, req, res, next) {
  res.status(err.status || 500);
  res.send(err);
});

module.exports = app;

This code initializes an Express.js app and adds middleware for JSON, URL encoding, and cookie parsing. We then include a usersRouter for /users. Finally, we define actions for when no route matches and how to manage errors, which we’ll modify later.

The server-starting script is located at /bin/start.js:

1
2
3
4
5
6
7
const app = require('../app');
const http = require('http');

const port = process.env.PORT || '3000';

const server = http.createServer(app);
server.listen(port);

Our Express.js promises example has a minimal /package.json:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "name": "express-promises-example",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node ./bin/start.js"
  },
  "dependencies": {
    "cookie-parser": "~1.4.4",
    "express": "~4.16.1",
    "http-errors": "~1.6.3"
  }
}

Let’s implement a standard user router in /routes/users.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const express = require('express');
const router = express.Router();

const userService = require('../services/userService');

router.get('/', function(req, res) {
  userService.getAll()
    .then(result => res.status(200).send(result))
    .catch(err => res.status(500).send(err));
});

router.get('/:id', function(req, res) {
  userService.getById(req.params.id)
    .then(result => res.status(200).send(result))
    .catch(err => res.status(500).send(err));
});

module.exports = router;

This router has two routes: / for retrieving all users and /:id for fetching a specific user. It utilizes /services/userService.js, which provides promise-based methods for data retrieval:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const users = [
  {id: '1', fullName: 'User The First'},
  {id: '2', fullName: 'User The Second'}
];

const getAll = () => Promise.resolve(users);
const getById = (id) => Promise.resolve(users.find(u => u.id == id));

module.exports = {
  getById,
  getAll
};

We’ve avoided using a database connector or ORM like Mongoose or Sequelize, simulating data fetching with Promise.resolve(...).

Challenges with Express.js Routing

Examining our route handlers reveals that each service call repeats .then(...) and .catch(...) callbacks to send data or errors to the client.

This might not seem significant initially. However, let’s introduce real-world requirements: displaying specific errors while hiding generic 500 errors based on the environment. Imagine the complexity when our example project grows from two routes to a real-world application with 200 routes.

Strategy 1: Utility Functions

One solution is creating separate utility functions for handling resolve and reject, using them throughout our Express.js routes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// some response handlers in /utils 
const handleResponse = (res, data) => res.status(200).send(data);
const handleError = (res, err) => res.status(500).send(err);


// routes/users.js
router.get('/', function(req, res) {
  userService.getAll()
    .then(data => handleResponse(res, data))
    .catch(err => handleError(res, err));
});

router.get('/:id', function(req, res) {
  userService.getById(req.params.id)
    .then(data => handleResponse(res, data))
    .catch(err => handleError(res, err));
});

This avoids repeating data and error-sending logic. However, we still need to import these handlers in each route and add them to every Express promise in then() and catch().

Strategy 2: Middleware

Another approach is using Express.js best practices around promises. We can move error-sending logic into Express.js error middleware (in app.js) and pass asynchronous errors using the next callback. Our basic error middleware might look like this:

1
2
3
4
app.use(function(err, req, res, next) {
  res.status(err.status || 500);
  res.send(err);
});

Express.js recognizes this as error middleware due to its four input arguments, leveraging the function object’s .length property indicating expected parameters.

Passing errors through next would resemble:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// some response handlers in /utils 
const handleResponse = (res, data) => res.status(200).send(data);

// routes/users.js
router.get('/', function(req, res, next) {
  userService.getAll()
    .then(data => handleResponse(res, data))
    .catch(next);
});

router.get('/:id', function(req, res, next) {
  userService.getById(req.params.id)
    .then(data => handleResponse(res, data))
    .catch(next);
});

Even following best practices, we still require JS promises in each route handler, resolving with a handleResponse() function and rejecting by passing the next function. Let’s explore a simpler method.

Strategy 3: Promise-based Middleware

JavaScript’s dynamic nature allows adding fields to objects at runtime. We’ll leverage this to extend Express.js result objects within middleware functions.

Implementing promiseMiddleware()

Let’s create promise middleware for more elegant Express.js routes. Create a new file, /middleware/promise.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const handleResponse = (res, data) => res.status(200).send(data);
const handleError = (res, err = {}) => res.status(err.status || 500).send({error: err.message});


module.exports = function promiseMiddleware() {
  return (req,res,next) => {
    res.promise = (p) => {
      let promiseToResolve;
      if (p.then && p.catch) {
        promiseToResolve = p;
      } else if (typeof p === 'function') {
        promiseToResolve = Promise.resolve().then(() => p());
      } else {
        promiseToResolve = Promise.resolve(p);
      }

      return promiseToResolve
        .then((data) => handleResponse(res, data))
        .catch((e) => handleError(res, e));  
    };

    return next();
  };
}

In app.js, apply this middleware to the Express.js app object and update default error handling:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const promiseMiddleware = require('./middlewares/promise');
//...
app.use(promiseMiddleware());
//...
app.use(function(req, res, next) {
  res.promise(Promise.reject(createError(404)));
});
app.use(function(err, req, res, next) {
  res.promise(Promise.reject(err));
});

We retain our error middleware for handling synchronous errors. However, instead of repeating error-sending, it passes synchronous errors to the central handleError() function via Promise.reject() called on res.promise().

This handles synchronous errors like this:

1
2
3
router.get('/someRoute', function(req, res){
  throw new Error('This is synchronous error!');
});

Finally, use res.promise() in /routes/users.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const express = require('express');
const router = express.Router();

const userService = require('../services/userService');

router.get('/', function(req, res) {
  res.promise(userService.getAll());
});

router.get('/:id', function(req, res) {
  res.promise(() => userService.getById(req.params.id));
});

module.exports = router;

Notice the different uses of .promise(): with a function or a promise. When passed a function, it wraps it in a promise.

Where should errors be sent to the client? It’s a matter of code organization. We could do it in error middleware (dealing with errors) or promise middleware (interacting with the response object). I prefer keeping response operations within promise middleware, but it’s up to the developer.

res.promise() Is Technically Optional

While we’ve introduced res.promise(), we’re not obligated to use it. Direct response object manipulation is still possible. Let’s look at redirecting and stream piping.

Special Case 1: Redirects

Let’s redirect users to another URL. Add a getUserProfilePicUrl() function in userService.js:

1
const getUserProfilePicUrl = (id) => Promise.resolve(`/img/${id}`);

Use it in the users router with async/await:

1
2
3
4
5
6
7
8
router.get('/:id/profilePic', async function (req, res) {
  try {
    const url = await userService.getUserProfilePicUrl(req.params.id);
    res.redirect(url);
  } catch (e) {
    res.promise(Promise.reject(e));
  }
});

We utilize async/await, perform the redirect, and maintain centralized error handling with res.promise().

Special Case 2: Stream Piping

Similar to redirects, piping a stream requires direct response object manipulation.

Let’s add a route to serve a generic picture at the URL we’re redirecting to.

Add profilePic.jpg in a new /assets/img subfolder. (In reality, we’d use cloud storage like AWS S3.)

Pipe this image in response to /img/profilePic/:id requests. Create a new router at /routes/img.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const express = require('express');
const router = express.Router();

const fs = require('fs');
const path = require('path');

router.get('/:id', function(req, res) {
  /* Note that we create a path to the file based on the current working
   * directory, not the router file location.
   */

  const fileStream = fs.createReadStream(
    path.join(process.cwd(), './assets/img/profilePic.png')
  );
  fileStream.pipe(res);
});

module.exports = router;

Add our new /img router in app.js:

1
2
app.use('/users', require('./routes/users'));
app.use('/img', require('./routes/img'));

Notice we haven’t used res.promise() in the /img router. This is because piping an already-piped response object with an error behaves differently than mid-stream errors.

Handling stream errors in Express.js requires attention, treating errors differently based on timing. Errors before piping can be managed with res.promise(), while mid-stream errors are handled using the .on('error') handler (beyond this article’s scope).

Extending res.promise()

We have flexibility in implementing res.promise(). promiseMiddleware.js can accept options within res.promise() to define response status codes, content types, or other project-specific needs. Developers can tailor their tools and code organization as needed.

Error Handling in Express.js with Modern Promises

This approach provides more elegant route handlers than our starting point, with centralized result and error processing, even outside res.promise(...). However, we’re not forced to use it and can handle edge cases as desired.

The complete code is available available on GitHub. Developers can add custom logic to handleResponse(), like changing the response status from 200 to 204 if no data exists.

The enhanced error control is even more beneficial. This approach allowed me to implement these production features concisely:

  • Consistent error formatting as {error: {message}}
  • Sending a generic message if no status is provided or passing along the given message
  • Populating the error.stack field in dev (or test) environments
  • Handling database index errors (e.g., duplicate unique-indexed entities) with meaningful user errors

All this Express.js route logic resides in one place, decoupled from services, enhancing maintainability and extensibility. This exemplifies how simple yet elegant solutions significantly improve project structure.

Licensed under CC BY-NC-SA 4.0