Skip to content

Motivation

Simon Sturmer edited this page Aug 14, 2023 · 5 revisions

Motivation behind building a shiny new, minimalist web framework

(AKA, what’s wrong with Express)

Express has been transformational in the Node and JavaScript ecosystem. It came out of the gate early (2010!) and it has managed to remain the dominant web framework for Node, powering countless high-performance production web applications.

However Express has an abundance of technical debt and legacy design decisions that make it difficult to adapt to the modern web, from spotty support for promises and async/await to an imperative API that makes it almost impossible to get strong type guarantees when using TypeScript.

The current state of affairs

The typical way of defining a route handler in Express looks something like this:

app.get('/users/:id', (req, res, next) => {
  const id = req.params.id;
  db.getUserById(id, (error, user) => {
    if (error) {
      next(error);
    }
    if (!user) {
      // No user found with the provided ID
      next();
    }
    res.json(user);
  });
});

This uses the callback approach and that requires us to call next() or req.end() or one of the helper methods that implicitly calls req.end() such as req.json() as we did above. Importantly, there's a subtle bug in the above code which is easy to miss and often trips up developers. We didn't return after calling next() so it will try to write to a response that's already ended. Not only are callbacks clunky for complex control flow but if we forget to call next() or req.end() or forget to handle an error our connection hangs indefinitely.

Of course these days we're not using callbacks in our code, we're using async/await, which has much cleaner control-flow. But with Express we still have the same issues around error handling, forgetting to end the request or accidentally writing after end.

app.get('/users/:id', async (req, res, next) => {
  const id = req.params.id;
  const user = await db.getUserById(id);
  if (!user) {
    next();
    return;
  }
  res.json(user);
});

Notice that with async/await we still need to remember to call next() or end the request for each branch of our business logic. Just like above, if we forget to return after calling next() there will be problems with write-after-end. But more subtly, you might have noticed there's no error handling here. You might assume that Express will handle it if our async function rejects, but this is not the case. If db.getUserById(id) rejects with an error, the error is never handled and the request will hang indefinitely. What's worse, it's possible the entire Node process will terminate on an unhandled promise rejection.

The correct way to handle errors in the above code would be to use try/catch everywhere you await something that might throw, which is messy to say the least. Here's what that looks like:

app.get('/users/:id', async (req, res, next) => {
  const id = req.params.id;
  let user;
  try {
    user = await db.getUserById(id);
  } catch(error) {
    next(error);
    return;
  }
  if (!user) {
    next();
    return;
  }
  res.json(user);
});

Besides being tedious and having poor error handling, a major drawback of Express code in general is that it's not declarative.

You might notice that the above function doesn't return anything. The Express API is built entirely around calling mutative methods on the response object. This makes it hard to compose request handlers and hard for the type system to give us any guarantees about the control flow of our code. TypeScript can't help us if we forget to call res.end() or next() or if we call them in the wrong order, or one too many times in one branch of our code.

Some of this will be be fixed the upcoming v5 of Express, but most of this cannot be fixed without a totally different kind of API.

A better way

If you think about it, the request is the input to a route handler, and the response is the result. One in, one out. Does it really make sense to take the input and the output as parameters, mutate a bunch of stuff and then return nothing?

Comparatively, more modern systems have all taken the approach of receiving a Request and returning a response (Deno, Bun, Cloudflare workers, service workers).

// Cloudflare workers example from https://blog.cloudflare.com/workers-javascript-modules/
export default {
  fetch(request) {
    if (request.url.endsWith("/index.html") {
       return new Response(html, {
          headers: { "Content-Type": "text/html" }
       });
    }
    return fetch(request);
  }
}

This is easier to test, easier to write types for and with this approach it's much harder to shoot ourselves in the foot (there's no way to end the request twice). I'd also argue the API feels much more natural to a modern TypeScript developer.

As a developer,

  • I want the system to take care of error handling for me, ensuring that we'll never accidentally leave a request hanging. Every route handler must either return or throw.
  • I want my request handlers to be well-typed async functions that take one input and return exactly one result.
  • I shouldn't be expected to mutate some shared object and we shouldn't hang arbitrary things off the request
  • I should be able to return undefined and the router should simply continue to the next handler, eventually sending a 404 if none handle it
  • I want TypeScript to know as much as possible about my code, giving me all the helpful type hints I've grown to expect and preventing me from doing anything stupid.

These things can't be achieved with Express. But by leveraging the web-standard Request/Response paradigm already supported by Bun, Cloudflare Workers and Deno, with a bit of TypeScript trickery, we can get all of the above and more!

Enter nbit, a minimalist, fully-typed, high-performance web framework for Bun, Node and Cloudflare workers.