How JavaScript works: exceptions + best practices for synchronous and asynchronous code

Alexander Zlatkov
SessionStack Blog
Published in
9 min readJan 5, 2021
Errors in the browser console

This is post # 20 of the series, dedicated to exploring JavaScript and its building components. In the process of identifying and describing the core elements, we also share some rules of thumb we use when building SessionStack, a JavaScript tool for developers to identify, visualize, and reproduce web app bugs through pixel-perfect session replay.

If you missed the previous chapters, you can find them here:

Overview

In computing, error detection is a technique that enables a reliable flow during the execution of the program.

One approach to error detection is error checking. This method maintains normal program flow with subsequent explicit checks for anomalies, which are reported using specific return values, an auxiliary global variable, or floating-point status flags.

An exception is the occurrence of an anomaly during the program execution, which interrupts its normal flow. Such an interruption triggers the execution of a pre-registered exception handler.

Exceptions can happen both on the software and the hardware level.

Exceptions in JavaScript

A single JavaScript application may have to run on many different operating systems, browsers + plugins, and devices. No matter how many tests you wrote, supporting such an environment of possibilities will ultimately lead to errors.

From an end-user’s standpoint, JavaScript is dealing with errors by just failing silently. Things are a bit more complicated under the hood.

A JavaScript code throws an exception when a particular statement generates an error. Instead of executing the next code statement, the JavaScript engine checks for the existence of exception handling code.

If no exception handlers have been defined, the engine returns from the function that threw the exception. This process is repeated for each function on the call stack until it finds an exception handler. If no exception handler is found and there are no more functions on the stack, the next function on the callback queue is added to the stack by the event loop.

When an exception occurs, an Error Object is created and thrown.

Types of Error Objects

There are nine types of built-in error objects in JavaScript, which are the foundation for exception handling:

  • Error — represents generic exceptions. It is most often used for implementing user-defined exceptions.
  • EvalError — occurs when the eval() function is used improperly.
  • RangeError — used for errors that occur when a numeric variable or parameter is outside of its valid range.
  • ReferenceError — occurs when a non-existent variable is accessed.
  • SyntaxError — occurs when the JavaScript language rules are broken. For static-typed languages, this happens during compilation time. In JavaScript, it happens during runtime.
  • TypeError — occurs when a value does not match the expected type. Calling a non-existent object method is a common cause of this type of exception.
  • URIError — occurs when encodeURI() and decodeURI() encounter a malformed URI.
  • AggregateError — when multiple errors need to be reported by an operation, for example by Promise.any().
  • InternalError — occurs when an internal error in the JavaScript engine is thrown. E.g. “too much recursion”. This API is not standardized at the moment of writing this article.

You can also define custom error types by inheriting some of the built-in error types.

Throwing Exceptions

JavaScript allows developers to trigger exceptions via the throw statement.

Each of the built-in error objects takes an optional “message” parameter that gives a human-readable description of the error.

It’s important to note that you can throw any type of object as an Exception, such as Numbers, Strings, Arrays, etc.

These are all valid JavaScript statements.

There are benefits to using the built-in error types instead of other objects since some browsers give special treatment to them, such as the name of the file which caused the exception, the line number, and the stack trace. Some browsers, like Firefox, are populating these properties for all types of objects.

Handling Exceptions

Now we will see how to make sure that exceptions don’t crash our apps.

The “try” Clause

JavaScript, quite similar to other programming languages, has the try, catch, finally statements, which gives us control over the flow of exceptions on our code.

Here is a sample:

The try clause is mandatory and wraps a block of code that potentially can throw an error.

The “catch” Clause

It is followed by a catch block, which wraps JavaScript code that handles the error.

The catch clause stops the exception from propagating through the call stack and allows the application flow to continue. The error itself is passed as an argument to the catch clause.

Commonly, some code blocks can throw a different kind of exception, and your application can potentially act differently depending on the exception.

JavaScript exposes the instanceof operator that can be used to differentiate between the types of exceptions:

It’s a valid case to re-throw an exception that has been caught. For example, if you catch an exception, the type of which is not relevant to you in this context.

The “finally” Clause

The finally code block is executed after the try and catch clauses, regardless of any exceptions. The finallyclause is useful for including clean up code such as closing WebSocket connections or other resources.

Note that the finally block will be executed even if a thrown exception is not caught. In such a scenario, the finally block is executed, and then the engine continues to go through the functions in the call stack in order until the exception is handled properly or the application is terminated.

Also important to note is that the finally block will be executed even if the try or catch blocks execute a return statement.

Let’s look at the following example:

By invoking the foo1() function, we get false as result, even though the try block has a return statement.

The same applies if we have a return statement in a catch block:

Invoking foo2() also returns false.

Handling Exceptions in Asynchronous Code

We won’t go into detail about the internals of async programming in JavaScript here, but we will see how handling exceptions is done with “callback functions”, “promises”, and “async/await”.

async/await

Let’s define a standard function that just throws an error:

When an error is thrown in an async function, a rejected promise will be returned with the thrown error, equivalent to:

Let’s see what happens when foo() is invoked:

Since foo() is async, it dispatches a Promise. The code does not wait for the async function, so there is no actual error to be caught at the moment. The finally block is executed and then the Promise rejects.

We don’t have any code that handles this rejected Promise.

This can be handled by just adding the await keyword when invoking foo() and wrapping the code in an async function:

Promises

Let’s define a function that throws an error outside of the Promise:

Now let’s invoke foo with a string instead of a number:

This will result in an Uncaught TypeError: x is not a number since the catch of the promise is not being able to handle an error that was thrown outside of the Promise.

To catch such errors, you need to use the standard try and catch clauses:

If foo is modified to throw an error inside of the Promise:

Now the catch statement of the promise will handle the error:

Note that throwing an error inside a Promise is the same thing as using the reject callback. So it’s better to define foo like this:

If there is no catch method to handle the error inside the Promise, the next function from the callback queue will be added to the stack.

Callback Functions

There are two main rules for working with the error-first callback approach:

  1. The first argument of the callback is for the error object. If an error occurred, it will be returned by the first err argument. If no error occurred, err will be set to null.
  2. The second argument of the callback is the response data.

If there is an err object, it’s better not to touch or rely on the result parameter.

Dealing with unhandled exceptions

If your application uses third-party libraries, you have no control over how they deal with exceptions. There are cases when you might want to be able to deal with unhandled exceptions.

Browser

Browsers expose a window.onerror event handler that can be used for this purpose.

Here is how you can use it:

This is what the arguments mean:

  • msg — The message associated with the error, e.g. Uncaught ReferenceError: foo is not defined.
  • url — The URL of the script or document associated with the error.
  • lineNo — The line number (if available).
  • columnNo — The column number (if available).
  • err — The Error object associated with this error (if available).

When the function returns true, this prevents the firing of the default event handler.

There can be only one event handler assigned to window.onerror because it is a function assignment, and there can only be one function assigned to an event at a time.

This means that if you assign your own window.onerror, you will override any previous handler that might have been assigned by third-party libraries. This can be a huge problem, especially for tools such as error trackers, as they will most likely completely stop working.

You can easily work around this problem by using the following trick:

Тhe code above checks if there was a previously defined window.onerror, and simply calls it before proceeding. Using this pattern, you can keep adding additional handlers to window.onerror.

This approach is highly compatible across browsers (it is supported even in IE6).

An alternative, which doesn’t require replacing handlers, is adding an event listener to the window object:

This approach is much better, and also widely supported (from IE9 onwards).

Node.js

The process object from the EventEmmiter module provides two events for handling errors.

  1. uncaughtException — emitted when an uncaught exception bubbles all the way back to the event loop. By default, Node.js handles such exceptions by printing the stack trace to stderr and exiting with code 1. Adding a handler for this event overrides the default behavior. The correct use of the event is to perform synchronous cleanup of allocated resources (e.g. file descriptors, handles, etc) before shutting down the process. It is not safe to resume normal operation afterwards.
  2. unhandledRejection — emitted whenever a Promise is rejected and no error handler is attached to the promise within a turn of the event loop. The unhandledRejection event is useful for detecting and keeping track of promises that were rejected and which rejections have not yet been handled.

It’s really important that error handling is properly taken care of within your code. It’s equally important to understand unhandled errors so that you can prioritize and work on them accordingly.

You can do this on your own, which can be quite tricky due to the wide variety of browsers, and all of the different cases that need to be taken care of. Alternatively, you can use some third-party tool to do this for you. No matter what option you choose, it’s very important that you have as much information as possible about the error and the user context on how the error was triggered so that you can easily replicate it.

SessionStack is a solution that lets you replay JavaScript errors as if they happened in your browser. You can visually replay the exact user steps that led to the error, see the device, resolution, network, and all of the data that might be needed to connect the dots.

There is a free trial if you’d like to give SessionStack a try.

SessionStack replaying an error.

Resources:

--

--