Node.js Framework Comparison: Express.js vs Hapi.js

Comparison: Express vs. Hapi

Today we are going to talk about two popular Node.js web application frameworks: Express and Hapi. While these frameworks seek to solve similar problems, they vary fundamentally in their approaches and each have their own advantages and disadvantages. This comparison should not be considered a “ranking” but rather an introduction to some of the high-level differences between the frameworks. If you are deciding which framework to use, or if you are just interested in learning a bit more about these tools, we hope to get you started in the right direction.

Table of Contents
Philosophy/Overview
Basic Server Setup
Routing
Response Handling
Extensibility
Error Handling
Authentication
Conclusion

All discussion should be taken in the context of Express version 4.14.0 and Hapi 13.5.0.

Philosophy/Overview

Express

Express is a code-centric web application framework that aims to provide developers with a simple, performant, and unopinionated toolset for creating web application servers. The API is kept lightweight and maintains a high degree of consistency with the NodeJS core API. Because of its minimalist nature, many common tasks require outside modules.

With millions of downloads per month, Express reigns as the most widely used Node.js web application framework. Having been in development since 2009, it is a mature project with a strong community backing.

Hapi

Hapi is a feature rich framework that favors configuration over code and attempts to cover a wider ranger of use cases out of the box. It was originally created by a member of WalmartLabs, and it is intended for large teams and large projects. Because of this, it can be a bit boilerplate-heavy for small projects.

While Hapi is less widely used and slightly newer than Express (Hapi started development in 2011), Hapi is used by a variety of large companies such as Walmart, Disney, and Macy’s.

Basic Server Setup

Before our application server can do anything, we have to create it! To start off, let’s make a new server, tell it to start listening to port 3000, and have it log a message when it has done so.

Express

const express = require('express');
const app = express();
 
const server = app.listen(3000, function() {
    console.log('Express is listening to port 3000');
});

Everything is pretty straightforward here. The express module returns a function (with some helper methods attached) which will return a new Express app. The return value of app.listen is an instance of http.Server.

Hapi

const Hapi = require('hapi');
 
const server = new Hapi.Server();
server.connection({
  port:3000
});
 
server.start(function() {
    console.log('Hapi is listening to port 3000');
});

This part looks very similar to what we did for Express, although Hapi’s focus on configuration over code is already beginning to make an appearance. After instantiating a new Hapi server, we add a connection, providing it with the port via a configuration object. Then, we simply start the server.

Routing

A server isn’t good for much if it can’t respond to incoming requests, so let’s implement some basic routing. We’ll respond to GET and POST requests for “/api/objects” and just GET requests for “/api/objects/[objectId]” since we wouldn’t be POSTing to a specific object. We will also respond to “/” for the sake of demonstration.

Express

const express = require('express');
const app = express();
 
const router = express.Router();
 
router.route('/objects')
  .get(function(req, res, next) {
    res.send('GET received for objects');
  })
  .post(function(req, res, next) {
    res.send('POST received for objects');
  });
 
router.route('/objects/:id')
  .get(function(req, res, next) {
    res.send('GET received for object with ID of ' + req.params.id);
  });
 
app.use('/api', router);
 
app.get('/', function(req, res) {
  res.send('Hello!');
});
 
const server = app.listen(3000, function() {
  console.log('Express is listening to port 3000');
});

Notice the varied options for route definition. To minimize code duplication, we are using the Router middleware; this allows us to define handlers for multiple HTTP methods for a single URI, while also attaching a route prefix to multiple URI’s.

The other routing mechanism in play here is the “app.get” method. Express offers a variety of app.XXX methods for different HTTP methods. For a complete list, see the app.METHOD documentation.

Hapi

const Hapi = require('hapi');
 
const server = new Hapi.Server();
server.connection({
  port:3000
});
 
function buildApiRoute(uri) {
  if (uri.indexOf('/') !== 0) {
    uri = '/'+uri;
  }
  return '/api' + uri;
};
 
server.route([
  {
    method: 'GET',
    path: buildApiRoute('objects'),
    handler: function(request, reply) {
      reply('GET received for objects');
    }
  },
  {
    method: 'POST',
    path: buildApiRoute('objects'),
    handler: function(request, reply) {
      reply('POST received for objects');
    }
  },
  {
    method: 'GET',
    path: buildApiRoute('/objects/{id}'),
    handler: function(request, reply) {
      reply('GET received for object with ID of ' + request.params.id);
    }
  },
  {
    method: 'GET',
    path: '/',
    handler: function(request, reply) {
      reply('Hello!');
    }
  }
]);
 
server.start(function() {
  console.log('Hapi is listening to port 3000');
});

Wow! That was a lot more code than we had to write with Express. Hapi’s configuration-centric approach does tend to mean more boilerplate, and (particularly for developers that rely heavily on an IDE with autocomplete) that can make it more error prone. There are also advantages, however: for example, it is consistent and highly self-descriptive, making it useful for larger team sizes. Moreover, any development errors in configuration should be caught quickly by Hapi’s validation systems. We’ll learn more about that in the Error Handling section.

Note: Since Hapi doesn’t support route prefixes in the server.route method, we went ahead and made a small helper function to prevent mistakes. See the Extensibility section for a better way to add prefixes to our routes.

Response Handling

Let’s discuss further something that we saw in the Routing section: response handling. Express and Hapi each approach response handling in their own way, with Express sticking closer to what we expect when working with Node.js and Hapi taking a bit more liberty.

Express

In the Routing section, we used the following Express code to respond to a request with some simple text content:

function(req, res) {
  res.send('Hello!');
})

The response object being used is an extended version of Node’s http.ServerResponse class. As such, it has all the typical fields and methods one would expect, as well as a variety of extras including res.download, res.end, res.json, and res.redirect. Here, we are using res.send, which is a multipurpose response function that supports Buffers, Strings, objects, and Arrays. For more information on the methods supported, see the relevant documentation.

Hapi

In the Routing section, we used the following Hapi code to respond to a request with some simple text content:

function(request, reply) {
  reply('Hello!');
}

As we can see, the reply function represents another example of Hapi’s deviation from Node’s API. While this can mean a slightly longer ramp-up time for developers getting started with the framework, it also grants the Hapi team greater freedom to strike their own path without worrying about maintaining compatibility with “vanilla” Node.

Unlike the Express app.send method, Hapi’s reply object (function) also includes support for (among other data types) Streams, Error objects, and Promises. Like the Express app.send method, Hapi’s reply object also includes a variety of methods for common use cases such as redirects.

For more information on Hapi’s response API, including additional methods and supported response types, see the Reply Interface documentation.

Extensibility

Obviously, it is important to be able to easily fill in the gaps that our framework leaves for us. Let’s take a look at how Express and Hapi each tackle this challenge.

Express

Express leverages middleware to extend route functionality. A middleware function in Express accepts arguments for request (conventionally “req”), response (“res”), next, and optionally error (“err”) objects. The request and response objects are used exactly as they were in our routes, the “next” object is a function that should be called at the end of any middleware that is not completing the response, and the “err” object should be used to intercept any errors that may have popped up in previous middlewares. Middlewares are run in the order they are registered, and error handling middlewares are typically saved for the end of the chain.

We have already seen middleware at play in the Routing section. While you may not have realized it at the time, all of our routing functions have been middleware, as was the Express router, which we used in conjunction the “app.use” method. Let’s add an additional middleware function with some validation to our “/api/objects/:id” route.

Let’s see it in action with some parameter validation:

router.route('/objects/:id')
  .get(
    function(req, res, next) {
      var validationError = null;
      if (req.params.hasOwnProperty('id')) {
        let validationResult = Joi.validate(
          {
            id: req.params.id
          },
          {
            id: Joi.number().integer()
          }
        );
 
        validationError = validationResult.error;
      }
 
      if (validationError) {
        return res.send(validationError);
      }
 
      next();
    },
    function(req, res, next) {
      res.send('GET received for ' + req.params.id);
    }
  );

Now let’s make a bad request to our newly validated route and see what we get back:

/api/objects/bob

{
  "isJoi":true,
  "name":"ValidationError",
  "details":[
    {
      "message":"\"id\" must be a number",
      "path":"id","
      type":"number.base",
      "context":{
        "key":"id"
      }
    }
  ],
  "_object":{
    "id":"bob"
  }
}

Here we can clearly verify that our middleware worked, successfully ending the response early when we submitted bad data. As we have seen, the Express middleware system is a simple, robust, and powerful solution to our need to handle extensibility within our application.

Hapi

Hapi uses a plugins system to help encourage the development of modular code suitable for large projects in a team environment. Let’s cover a frequent use case by splitting our routes across multiple files and providing them with a common route prefix. We will also add some validation to our “id” route parameter.

First, let’s define our plugins:

plugins/objects.js

exports.register = function (server, options, next) {
  const uri = '/objects';
 
  server.route([
    {
      method: 'GET',
      path: uri,
      handler: function(request, reply) {
        reply('GET received for objects');
      }
    },
    {
      method: 'POST',
      path: uri,
      handler: function(request, reply) {
        reply('POST received for objects');
      }
    }
  ]);
 
  next();
};
 
exports.register.attributes = {
    name: 'objects',
    version: '1.0.0'
};

plugins/objectById.js

const Joi = require('joi');
 
exports.register = function (server, options, next) {
  server.route({
    method: 'GET',
    path: '/objects/{id}',
    handler: function(request, reply) {
      reply('GET received for object with ID of ' + request.params.id);
    },
    config: {
      validate: {
        params: {
          id: Joi.number().integer()
        }
      }
    }
  });
 
  next();
};
 
exports.register.attributes = {
    name: 'objectById',
    version: '1.0.0'
};

A plugin is simply an object which defines a “register” method accepting three parameters (server, options, next), and declaring some relevant attributes. When called, this object’s register method is provided with the related server object, any options the plugin has been provided (outside the scope of this article), and a function (next) to call when the plugin’s functionality is complete. The only type of argument that should be passed to the next function is, if applicable, an error object.

Plugins aren’t much good by themselves, so let’s put our new code to use! We will now place our original code in an “app.js” file one level above the plugins.

app.js

const Hapi = require('hapi');
 
const server = new Hapi.Server();
server.connection({
  port:3000
});
 
server.route({
  method: 'GET',
  path: '/',
  handler: function(request, reply) {
    reply('Hello!');
  }
});
 
server.register(
  [
    {register: require('./plugins/objects.js')},
    {register: require('./plugins/objectById.js')}
  ],
  {
    routes: {
      prefix: '/api'
    }
  },
  function(err) {
    if (err) {
      console.error('Error loading plugin: ', err);
    }
  }
)
 
server.start(function() {
  console.log('Hapi is listening to http://localhost:3000');
});

We have now added a call to server.register, providing it with the plugins to register, some configuration (the route prefix), and a function to call after our plugins have finished loading.

All of our routes remain functional, exactly as they were, and if we make a request to a route providing an invalid “id” parameter we will get a helpful error message, similar to the one we saw using when using Joi with Express:

/api/objects/bob

{
  statusCode: 400,
  error: "Bad Request",
  message: "child \"id\" fails because [\"id\" must be a number]",
  validation: {
    source: "params",
    keys: [
      "id"
    ]
  }
}

That’s it! For more information on the functionality supported by the Hapi Plugins system, see the Plugins API documentation.

Also, see the plugins list on Hapi’s official website for a list of useful plugins covering documentation, messaging, sessions, and more.

To take the next steps with extending your Hapi application (and to gain a better general understanding of how Hapi handles requests), we recommend checking out the documentation on the request lifecycle, extension events, and route prerequisites.

Error Handling

Understanding how our framework responds to errors is a critical part of building our application. Let’s take a look at how Express and Hapi respond to various types of errors.

Express

Express uses middleware for error handling. By simply registering a middleware that accepts an extra parameter at the beginning of its list, we can intercept any errors that occur earlier in the chain:

app.use('/', function(req, res, next) {
  throw new Error('Something went wrong!');
});
app.use('/', function(err, req, res, next) {
  if (err) {
    // Handle error
  }
  next();
});

But what happens if we don’t register an error handling middleware, or an uncaught error bubbles up at the end of (or after) our error handling chain?

Error: Something went wrong!
   at /Users/justin/Documents/workspace/blog/express-vs-hapi/testing/express-server.js:5:9
   .
   .

Uh oh! Uncaught errors here result in a text/html response containing a stack trace.

Now, let’s try using an Express middleware with some bad configuration:

app.use(express.static(‘public’, {redirect: ‘no thanks’, badProp: ‘yes please!’}));

As you might guess, “no thanks” is not a valid value for the redirect property, and “badProp” is not a valid property. Express will not complain, however. Instead, it will ignore the bad properties/values and continue along, business as usual. Be very careful, as this could be a source of confusing bugs!

Hapi

Hapi uses Boom for error reporting. Any uncaught errors will be wrapped in Boom objects and rendered appropriately.

The following code:

server.route({
  method: 'GET',
  path: '/',
  handler: function(request, reply) {
    throw new Error('Something went wrong!');
    reply('Hello world');
  }
});

…will result in a relatively uninformative application/json response:

{
  statusCode: 500,
  error: "Internal Server Error",
  message: "An internal server error occurred"
}

Of course, just as with Express, we should always take great care to prevent this from becoming an issue!

With Express, if we supplied invalid configuration, our application would ignore the mistake and continue forward. How does Hapi handle this situation?

Code:

server.route({
    method: 'GET',
    path: {},
    config: {handler: function(request, reply) {
        reply('Hello world');
    }},
    badProp: true,
    otherBadProp: false
});

Response:

Error: Invalid route options (GET [object Object]) {
  "method": "GET",
  "config": {
    "handler": function (request, reply) {\n        reply('Hello world');\n    }
  },
  "badProp": true,
  "otherBadProp": false,
  "path" [1]: {}
}

[1] "path" must be a string

Excellent! Hapi has provided us with a helpful error message informing us that the value we supplied to path is invalid. Let’s fix that and try again:

Error: Invalid route options (GET /) {
  "method": "GET",
  "path": "/",
  "config": {
    "handler": function (request, reply) {\n        reply('Hello world');\n    }
  },
  "otherBadProp" [1]: false,
  "badProp" [2]: true
}

[1] "badProp" is not allowed
[2] "otherBadProp" is not allowed

Once the bad value was resolved, Hapi also alerted us of our bad property names. Once we remove or fix those invalid properties, of course, we will see the expected response.

Hapi’s config validation helps highlight bugs early, which should make us very hapi…I mean, happy.

Authentication

While authentication is a bit outside the scope of this article, it is worth mentioning. We will cover, at a high level, how each framework handles authentication.

Express

Express handles authentication via middleware, giving the developer a great deal of freedom as to how to go about it. There are a wide variety of high-quality, open source packages available that serve this purpose for generic implementations.

Hapi

Hapi leverages two main concepts for authentication: schemes and strategies. Schemes are essentially functions that define basic methods of authentication. Schemes act as a sort of template for us to use when implementing our authentication Strategies. Strategies, on the other hand, can be thought of as pre-configured and named instances of schemes. This is where we tend to put the more business-logic oriented parts of the authentication process.

For more detailed information on implementing authentication with Hapi, check the Server documentation.

Conclusion

Express and Hapi are both web application frameworks with their own strengths and weaknesses. Express is an excellent lightweight tool for quickly building applications large or small, and is broadly applicable to a wide variety of situations. Hapi, too, is relatively flexible, but geared more towards larger teams and projects, and providing a greater wealth of functionality out of the box. We hope we have helped you to learn more about the similarities and differences between Express and Hapi, and set you in the right direction for accomplishing your goals.

If you have questions or comments, or if you would like to see more information on any of the topics we have covered here, we would love to hear from you in the Comments section. Happy coding!

Leave a Reply

Your email address will not be published. Required fields are marked *

*

*