Js

Russell Waterhouse | May 26, 2025 | 9 min read

My Relationship with JavaScript

Disclaimer

Quick disclaimer here, for the rest of the article, you can substitute JavaScript with TypeScript in your head if you prefer it. Everything I say about JavaScript in this article applies to both JavaScript and TypeScript, and no issue discussed below is fixed by a type system.

Why I’m writing this:

I am a professional developer. I get paid to sit down at my desk and put my fingers on the keyboard and write code, among other things.

For the last 6 months, I’ve noticed that I can do that for much longer periods in other programming languages I can in JavaScript. After 3 hours in JavaScript, I feel the same amount of mental fatigue that I feel after 4 of Ruby or 5 of Rust.

That’s not because writing code in JavaScript is hard, it’s not.

Nor is it because writing code in JavaScript is time-consuming; I believe JavaScript to be the language that I can write a proof-of-concept in fastest.

It’s because as soon as I hit a problem in JavaScript, it takes far too long to debug.

I think the reason is that everything that I have to do in JavaScript feels like it is much more time and effort than it should be or could be in other languages. I think this is because of a couple of features of the language and the ecosystem that intersect in painful ways.

Problem 1. Exceptions instead of errors-as-values.

In order for me to maintain control flow at all times, every line of code that I write that could throw an exception must be covered by a try-block that I write. Otherwise, when that innocent line of JavaScript does throw an exception, I have totally lost control. Either the framework that I’m using will catch the error, or I’ve crashed my program.

Additionally, if I want to handle two different errors differently, I either need to have conditional logic in my catch block, or I need to have two different try-catch blocks for different function calls that could throw an exception.

I’m not perfect, and even though I’m aware that I need to write try-catch blocks, I can have code that throws exceptions that I do not catch and have to track down. Normally, this happens because I’ve written a helper-function that I assume is called in a try-catch block one level of abstraction higher, but I forgot about a code-path that doesn’t have a try-catch block.

Tracking down exceptions in the JavaScript ecosystem is often trivial. Except when paired with Problem 2.

Before we get there though, let me show you an example of what I mean. Say our initial working implementation of a backend function that parses some uploaded JSON looks like this:


export async function insertParsedValue(req, res) {
  const reqJson = req.body;
  const db = await getDBConnection();
  const dbResponse = await db.insert({
    foo: reqJson.foo,
    bar: reqJson.bar,
  });
  return res.status(201).json({
      status: "ok",
      userMessage: "Record successfully created",
  });
}

And if that code worked, that would be great! There’s nothing in there that I’m going to complain about. However, programming doesn’t work like that. Users upload bad data, databases go down, Venezuela’s currency becomes so worthless that it gets rounded to zero and your currency-handling code is now blowing up at 2 am because you’re dividing by zero.

So, handling these errors, your code might instead look like this:


export async function insertParsedValue(req, res) {
  try {
    let reqJson;
    try {
      reqJson = req.body;
    } catch (e) {
      return res.status(400).json({
        status: "error",
        userMessage: "The JSON file you tried to upload is not valid JSON.",
      });
    }

    if (!("foo" in reqJson) || !("bar" in reqJson)) {
      return res.status(400).json({
        status: "error",
        userMessage: "The JSON file you tried to upload is not formatted correctly. Please ensure both a `foo` and a `bar` key are present"
      });
    }

    try {
      const db = await getDBConnection();
    } catch (e) {
      console.error(e);
      await notifyPagerDutyOfCriticalError("The database is down!");
      return res.status(500).json({
        status: "error",
        userMessage: "We have encountered an error and are investigating. We apologize for the inconvenience",
      });
    }
    const dbResponse = await db.insert({
      foo: reqJson.foo,
      bar: reqJson.bar,
    });
    if (dbResponse.ok) {
      return res.status(201).json({
          status: "ok",
          userMessage: "Record successfully created",
      });
    }

    return handleDBErrorMessage(dbResponse, req, res);
  } catch (e) {
    console.error(e);
    await notifyPagerDutyOfCriticalError(`Something unexpected happend and should be investigated: ${prettPrintError(e)}`);
    return res.status(500).json({
      status: "error",
      userMessage: "We have encountered an error and are investigating. We apologize for the inconvenience",
    });
  }
}

Now those that are keen among you might know that there are clever ways to make this not look so bad. And you’re right. See appendix A for more details.

My point is, handling each error uniquely with try-catch doesn’t spark joy for me.

Problem 2. Async/Await and Promises.

Now, in defence of Async/Await, I think it is far far far superior to the callback hell that plagued the JavaScript ecosystem not so long ago.

However, I still don’t think it’s the right abstraction for concurrent programming. I understand that in IO-heavy applications, such as the web often is, it can look like a good abstraction.

The single core in this lambda can do other things in the event loop while we’re await-ing this network request to resolve. And all without threads! Isn’t that awesome!

And quite frankly, no I don’t think it’s awesome. Real example here, let’s say you’re writing JS on the backend, and you have two functions like this:

async function notifyPagerDutyOfCriticalError(errMsg) {
...
}

async function assertOrNotifyPagerDuty(assertion, errMsg) {
  if (!assertion) {
    await notifyPagerDutyOfCriticalError(errMsg);
  }
}

In languages without async/await, I could use that assertOrNotifyPagerDuty function anywhere I like to assert invariants in my program.

Not so in JavaScript, in JavaScript I can only use that async function in other async functions. If I want to use this nice assert function I made, oops now that function and every one that calls it is async.

And in addition to that, I’ve found that async makes things like debugging a little bit harder. Ever seen a stack trace like this:

Error: Something went wrong while processing the request
    at doDatabaseCall (/app/node_modules/database_caller/index.js:42:15)
    at async doDatabaseThing (/app/node_modules/database_thing_2/index.js:88:13)
    at next (/app/node_modules/express/lib/router/route.js:144:13)
    at Route.dispatch (/app/node_modules/express/lib/router/route.js:114:3)
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
    at /app/node_modules/express/lib/router/index.js:284:15
    at Function.process_params (/app/node_modules/express/lib/router/index.js:346:12)
    at next (/app/node_modules/express/lib/router/index.js:280:10)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)

Not a single line in that stack trace goes back to a line of code in my app. Obviously, this is a made up example, but I often have stack traces that are like this, and such stack traces are almost worthless when I’m trying to figure out what’s actually wrong in the code that I wrote. I know I’ve awaited some function call to a dependency that I’ve passed bad data to, but that’s all that the stack trace tells me. If I’m lucky, I’ve only used the dependency in the stack trace once or twice in my app. I’m not often that lucky.

Conclusion

I’m very productive in JavaScript, but these two features of the language are starting to wear on my nerves a bit. Thanks for reading my rant.

Appendix A: Cleaning up my error handling example

I did the error handling example to show that handling lots of different errors differently is hard in JavaScript. All of the error handling code I wrote there has to live somewhere, so let’s clean it up and see how nice we can make it.

For the first step, we don’t need status in the response json, the HTTP response code (i.e. 500) will let us know that.


export async function insertParsedValue(req, res) {
  try {
    let reqJson;
    try {
      reqJson = req.body;
    } catch (e) {
      return res.status(400).json({
        userMessage: "The JSON file you tried to upload is not valid JSON.",
      });
    }

    if (!("foo" in reqJson) || !("bar" in reqJson)) {
      return res.status(400).json({
        userMessage: "The JSON file you tried to upload is not formatted correctly. Please ensure both a `foo` and a `bar` key are present"
      });
    }

    try {
      const db = await getDBConnection();
    } catch (e) {
      console.error(e);
      await notifyPagerDutyOfCriticalError("The database is down!");
      return res.status(500).json({
        userMessage: "We have encountered an error and are investigating. We apologize for the inconvenience",
      });
    }
    const dbResponse = await db.insert({
      foo: reqJson.foo,
      bar: reqJson.bar,
    });
    if (dbResponse.ok) {
      return res.status(201).json({
          userMessage: "Record successfully created",
      });
    }

    return handleDBErrorMessage(dbResponse, req, res);
  } catch (e) {
    console.error(e);
    await notifyPagerDutyOfCriticalError(`Something unexpected happend and should be investigated: ${prettPrintError(e)}`);
    return res.status(500).json({
      userMessage: "We have encountered an error and are investigating. We apologize for the inconvenience",
    });
  }
}

Second, we’re probably going to have all of these userMessages in translations files somewhere and accessed by keys. This will clean up the magic strings in the code.


export async function insertParsedValue(req, res) {
  try {
    let reqJson;
    try {
      reqJson = req.body;
    } catch (e) {
      return res.status(400).json({ userMessage: strings.invalidJSON });
    }

    if (!("foo" in reqJson) || !("bar" in reqJson)) {
      return res.status(400).json({ userMessage: strings.jsonKeysError });
    }

    try {
      const db = await getDBConnection();
    } catch (e) {
      console.error(e);
      await notifyPagerDutyOfCriticalError("The database is down!");
      return res.status(500).json({ userMessage: strings.investigatingError });
    }
    const dbResponse = await db.insert({
      foo: reqJson.foo,
      bar: reqJson.bar,
    });
    if (dbResponse.ok) {
      return res.status(201).json({ userMessage: strings.record_created_success });
    }

    return handleDBErrorMessage(dbResponse, req, res);
  } catch (e) {
    console.error(prettPrintError(e));
    await notifyPagerDutyOfCriticalError(`Something unexpected happend and should be investigated: ${prettPrintError(e)}`);
    return res.status(500).json({ userMessage: strings.investigatingError });
  }
}

Already, this is looking a lot better, but we can go a few steps further.

notifyPagerDutyOfCriticalError should take an error as a param to log, and getDBConnection should be refactored to notify pager duty if something was wrong, and just return a falsey value if something did.


export async function insertParsedValue(req, res) {
  try {
    let reqJson;
    try {
      reqJson = req.body;
    } catch (e) {
      return res.status(400).json({ userMessage: strings.invalidJSON });
    }

    if (!("foo" in reqJson) || !("bar" in reqJson)) {
      return res.status(400).json({ userMessage: strings.jsonKeysError });
    }

    const db = await getDBConnectionOrNull();
    if (!db) {
      return res.status(500).json({ userMessage: strings.investigatingError });
    }

    const dbResponse = await db.insert({
      foo: reqJson.foo,
      bar: reqJson.bar,
    });
    if (dbResponse.ok) {
      return res.status(201).json({ userMessage: strings.record_created_success });
    }

    return handleDBErrorMessage(dbResponse, req, res);
  } catch (e) {
    await notifyPagerDutyOfCriticalError("Something unexpected happend and should be investigated.", e);
    return res.status(500).json({ userMessage: strings.investigatingError });
  }
}

There are still improvements you could make, but I’ll stop here lest this become a refactoring blog post.

I want to be very clear here, the fact that I can say “In a real production system this error handling logic would be distributed among more modules and abstractions” does NOT invalidate the point that I’m making here. The point that I’m making with the error handling code is that a lot of error handling code needs to be written, and to respond differently to each unique runtime error this try-catch paradigm means that this gross error handling code needs to live somewhere. Moving it around into its own abstractions is the right thing to do, but we can’t make the mistake of saying the chaos doesn’t exist just because the chaos is well-managed.