Promises, Async, and Beyond II: Error Handling

Duncan, a front-end tooling engineer, covers error handling in JavaScript promises and async code. He explains why unhandled promise rejections happen, how async/await breaks stack traces, and what floating promises are. He proposes a useAsyncEffect hook to centralize async work and fix these problems.
Questions this video answers
Duncan (he/him), a front-end dev tooling engineer.
Epic Week series — this is part two (Piotr presented part one).
Chapters
I'm Duncan, pronounced he him and I work on the front end dev tooling team, which means I work on front end infrastructure related to TypeScript, Linters, the editor, that kind of thing. And for this talk, that's kind of important because this is all coming from an infrastructure perspective, which I'll talk a little bit more about in a minute, but kind of means that we're focusing on certain areas that infra can touch. Also, this is part two of a two part Epic Week series, so Piotr presented part one on Tuesday and there's a Slack recording you can go watch. So in part one, Piotr covers how asynchronous code is actually executed and managed by the V8 engine, and also casually knocks out Promise implementation in about 15 minutes, which I think is worth the price of admission on its own. So I highly recommend going back and watching that. Before we get into how specifically asynchronous error handling works, I'm going to lay some groundwork for what error handling is in general and what it should look like at least. So when we say error handling, we tend to be talking about a few different scenarios. We lump them all in with the same terms. So I kind of want to draw a distinction between at least two different kinds of error handling here. So one kind of error handling that you're going to be doing all the time is anticipated error handling, where you're doing some sort of IO operation or you're parsing a string into JSON. You're doing something that you know has a chance of causing an error to be thrown. And in those cases you're typically going to have error handling that's very close to the actual code that's running, and you're going to catch those errors, explain to the user exactly what went wrong, you're going to track what happened, but it's not necessarily going to be something that blows up the world and we need to go immediately fix, because we know that sometimes we'll get errors here. And I want to distinguish between that anticipated error handling and then the kind of error handling that we have in place to deal with errors that were considered either impossible or just not worth treating as an anticipated error. Places where we have kind of a default fallback behavior for what should happen if an error arises in any of the code in this app or within this error boundary. That kind of idea, and touching back on that, like I'm an infrastructure point, I'm a lot more focused on the second case. In general, I have opinions about what anticipated error Handling should look like, but it's not something that my tooling can help you with quite as much day to day. So this talk is going to lean a little bit more into that unanticipated error handling situation and then I'm going to kind of a simplification for the purposes of moving quickly here. But anything outside of those flows we're just going to say isn't really great error handling for now. I realize there's more nuance than that, but let's not talk about just that. So what we end up seeing in the real world is that engineers definitely know that network network requests are I O operations. They can fail for all kinds of different reasons. Sometimes it's just going to happen randomly, Sometimes there's like network problems. There also a point where your code is interfacing with another deployable which is always a little bit, you know, less reliable than your code interacting with stuff that it's been bundled with. So in a lot of ways network requests should, or failing network requests should be treated as anticipated errors. But a lot of times what we see is that when a network request fails, it ends up surfaced in Our error reporting UIs and tooling as though it was just any kind of runtime error. Like if you had a JSON stringify or sorry, a JSON parse error, it would show up in the same information stream as a failed network request. And kind of even worse, when you get a error report about a failed network request, the stack trace that you get isn't very informative. You'll oftentimes get kind of what you see in this example here, where there's a stack trace that begins and ends in Hub HTTP. So you're a library or app owner and your stack trace doesn't ever touch the code that you maintain, but somehow the error is attributed to the package that you're maintaining. And there are some tools for dealing with that kind of lack of information, but it's not ideal in the first place to just never see where you called code that then failed. So I think the first thing that I want to kind of get out of the way is that I don't want to make the tone of this talk at all. Like everybody is doing error handling wrong and people need to change behavior to fix it. That's not really the idea that I'm going for more. What I'm getting at is that there isn't a good system of tooling and like guardrails in place to make it obvious when there is missing error handling. Like an ideal world is one where if you are trying to achieve a certain goal, tooling helps you get there. And if you're for some reason doing something that isn't getting you closer to your goal, then the tooling should help you. It should push you back onto the happy path. And we don't really have that today. So kind of the high level problem, or at least the framing I want to have for how to solve this, is we want to solve for the gap between the knowledge that engineers have about network requests being able to fail and their intent about error handling, and then these kinds of weird experiences that we actually get on the receiving end of these error reports. So that's all well and good, but the first thing I'm going to do is actually backtrack way down into the weeds and we're going to talk about unhandled promise rejections and smaller topics and then build back up into some of the bigger stuff. So unhandled promise rejections kind of like the elephant in the room for this particular talk, right? So if you watched Piotr's talk, you know that an unhandled promise rejection is what happens when a promise hits its reject loop or its reject method, but it didn't have a catch handler registered at the time that the reject happened. So that can be because a catch handler is going to be added asynchronously, which is a misconception about how promises work. That doesn't actually end up resolving properly. That could be because there's just never going to be a catch handler. It doesn't matter. They all just end up being unhandled promise rejections. The way that works is the promise sees that it doesn't have any handler to deal with the rejection, and it just kind of falls back on the behavior of throwing it into the synchronous call stack. So that behavior might sound a little bit familiar if you've been dealing with recent, like eslint behavior changes. And we'll touch a little bit more on that soon here. So I think one interesting question about unhindered promise rejections is, are they extra bad? So if what we said in the previous slide is true, then an unhandled promise rejection is an unhandled error. There isn't a distinction between the two, at least in the context of the browser. And we know that unhandled errors are bad, but we're not having a tech talk about dealing with just unhandled errors as like a category of objects. They're not all that interesting. We kind of understand how to deal with throws that happen in synchronous code. So why do we have so much attention paid to unhandled promise rejections as a topic? And I think it's a little bit of like a. Your brain is running pattern recognition and it's arriving at conclusions that aren't quite true. So the underlying problem here isn't the unhandled promise rejection part, it's the asynchrony part. So even if we weren't throwing unhandled promise rejections, if they were just unhandled errors, the fact that they're happening asynchronously makes your error handling life worse. So when you have asynchronous code, or I should start with the sync example. So if you have a sync code base, everything in the code base is synchronous. At some point you're going to have a main function, and that main function could hypothetically have a try catch block that contains the rest of the code that runs in your entire program. It doesn't matter what other code you're looking at, if you have error handling at the top level, it will always hit that default error handling case. That's not how asynchronous error handling works. So if you are spinning up asynchronous microtasks constantly, then each of them is kind of going to execute in its own call stack, its own synchronous call stack. So if you're looking to have default fallback error handling behavior, you need to add it in more places, but it's not obvious where those places are. So sometimes when you write then and then you stick it in an arrow function, that's a place where error handling should go. Because you're writing another, you're essentially creating a new main function every time you do a vet. But at the same time, you don't want that error handling to exist in every single layer of a promise chain. You really only need it in the outermost layer, which is kind of like the top of our weird artificial call stack in an asynchronous code example. So kind of a more like practical example that we see day to day. If you have a component and it has some kind of side effect that effectively dispatches an action that calls some backend. This code, when you take a thunk and you pass it to dispatch, dispatch will call that thunk with itself, and then the dispatch function will return whatever the thunk returned, in many cases a promise. And it's not obvious in this code, but that means that we're just Taking a reference to a promise that we constructed on demand and then throwing it away. And that means that there's never going to be any chance to add any error handling for that promise. Now, in some cases, like fetch my stuff here, this thunk action might make it very difficult for the thunk to reject. It might be that there's a lot of error handling inside of that code, but from the perspective of our component, that's all implementation details. And really you shouldn't care. We're already dealing with the concept of errors that we did not expect to see. We should have some kind of acknowledgement of that. This is basically a main function, and we should be doing some kind of acknowledgement that if an error gets thrown here, we are exposing it to ourselves for debugging purposes. So if we're missing these cues to add error handling where we need it, if we're missing cues for when asynchronous code isn't going to be covered by our default error handling, then we should add some queues, right? So enter the concept of a floating promise. So you as a human have a hard time reading code and telling exactly where a promise is being thrown away. But if you give a machine enough type information, it can tell when a function returns a promise and it can tell when, like a promise reference is freed up to be garbage collected. And it's those cases where a promise reference is thrown away with no error handling that we call a floating promise. Hence the new ESL hand no floating promises rule, which I think everybody's probably encountered at least once at this point. And it ends up kind of prompting you to do this kind of stuff in your code, right? You dispatch, fetch my stuff, and then you write a catch because you're explicitly acknowledging, hey, I need some default error handling here. What if something goes wrong that we didn't anticipate? And the problem with this is that we didn't really provide any advice on, like, what should the catch handler do? The official guidance is you take the error and you rethrow it. So what that effectively says is do the exact same behavior that you would have done without the catch. Now that seems a little bit ridiculous, but like, the no floating promises rule is solving a bunch of other problems at the same time that we're not really talking about right now. And also it is still useful to have these explicit acknowledgments if this is a place where we are opting not to do error handling, right? Or at least not special area. So this is very common in our code base right now. So if we have a whole bunch of these catch and rethrows, but they're just doing the same thing that they would have done otherwise. Well, we already talked about how the default behavior isn't very desirable. We don't get useful stack traces from that. So we also need to think about providing better options for callbacks to put into that catch function, for example. So like, kind of the summary here is like, we can tell you all the places where error handling is needed, but if there isn't actually something you can do with that information, then who cares? So a catch and rethrow is often much easier to add than anticipated error handling, which is something I haven't really touched on too much. But like we expect, there are cases where network requests are going to be treated as unanticipated errors, even though we know they can throw, just because it's way easier to do that. And there's a lot of network requests being made. And I think that's a whole like, separate topic that I'm not really going to delve into here. And then, yeah, we don't really have a great recommendation for you before the catch rethrow, so we're have a lot of catch rethrow. So what do we do to get better stack traces? And this is kind of like the meat of kind of playing around with asynchronous stack traces and seeing how it behaves. Cool. So here I've got a really simple program, got a main function that calls a throwasync function, that set timeouts and then just throws. So the point of this is to create a asynchronous exception that isn't dealing with any promises. Like, this is just the basic case. When you run this, you get a stack trace and it has all the problems that we described earlier. It doesn't tell you where the synchronous code that triggered this code to run is. It tells you there was an anonymous function that threw an error. So in this case, if that anonymous function code was contained in Hub HTTP, then you as an app or library owner don't know where you called Hub HTTP in a way that broke. So we want to try and improve upon that behavior. So I think a good first impulse here is, okay, we have is async function syntax and it kind of looks like it should just fix this problem, right? Synchronous functions do all this try catch stuff in a very straightforward way that we're familiar with. Async functions appear to have the same syntax. Let's just try it and see what it does. So if you implement that and run it, you get promising results, right? You run main throw async throws, you get a stack trace that includes main and throwasync life is good. But then you start putting this into actual production code and deploy it, and your stack traces continue to come back without a whole bunch of context. So it turns out this doesn't work. And the reason it doesn't work is kind of an infuriating little wrinkle of JavaScript that I kind of find entertaining. So if you take that same code example and you just stick an await promise resolve right above your throw, your stack trace changes. Now, you're still going to see the throwasync function, but the main function is no longer included in your call stack. Why is that? So async functions are not entirely async. It turns out the first part of an async function up to the first await runs synchronously, and then the rest of the function runs in microtask like you'd expect. And so the reason for that, like if you watch Pyotrstalk, is because the promise constructor is synchronous, right? It kind of makes sense if you squint, but it's weird and unexpected in this context. So that means we're still missing some important stack information even when we're using async functions. So this kind of leads to some counterintuitive conclusions about how error handling could work. We could make it so that every time you run synchronous code that triggers asynchronous code, you're expected to capture an error that you might later row so that you can keep a trace of where the sync code that triggered the async code was, then have two stack traces. But like, that's weird. I don't really want to force that kind of code upon all kinds of code bases, and I don't want to have to maintain it across all those different sites. Also, like, we shouldn't be writing code that relies on language trivia in quite that way in settings that are copied around all over the place. Like, if we're going to do weird things that rely very closely in JavaScript engine details, then we should probably cordon that off in libraries and stuff. So that intuition, if you kind of like follow it a few more steps, actually ends up making a lot of sense. We want to move all of the places where in your code promises get thrown away and get garbage collected. We want to move all of those sites actually to libraries. So you'll notice if you're using something like data fetching client, you don't actually ever have a spot in your code where there's a synchronous function that makes a promise and then just doesn't do anything with it. That kind of only exists if you're using useeffect by itself. So we want to invest further in these kinds of libraries over time. But that's not a now solution, right? Like today, you've got a whole bunch of code that has useeffect or other synchronous code blocks that are triggering async effects that occur. And yeah, data fetching client just isn't being used widely, or at least not in a way that's comprehensive enough for me to feel comfortable saying that that's a solution to this problem. So when we start looking for ways that we can try and be a little bit more helpful, or if we're talking with people about what can we do to be more helpful, I think something that comes up really often is, hey, we want some kind of magic handler that we can import and stick into big catch. So that's, I think, a good impulse to have shared behavior that we could all just reuse, but it doesn't actually solve all the problems with this component that we keep on looking at. So not only does the magic handler here not necessarily give you all the call stacks you want, we could kind of do some weird language stuff to give you better stack traces, but not perfect. It also has race conditions. For example, this code doesn't actually prevent situations where the component re renders several times in a row with different IDs, and then the first network request you made resolves after the last one, and that controls your redux state. It's entirely dependent on how your fetch my stuff thunk is implemented. And not all funks are implemented in ways that are especially careful. In a lot of cases, we're just trusting libraries are doing this correctly or just chaining a whole bunch of network requests together. It's kind of the wild west out there. There's all kinds of different approaches being taken. We've got those race conditions. There's also just the problem of if you re render this component over and over and over again with different IDs, it's going to call the backend a whole bunch of times. And it's not really going to use any of the responses it gets back. It's only going to use the most recent one where accurately whichever one resolves last. So like this isn't good code, even if we have a magic handler in some sense. So kind of with that in mind, we still want to meet people where they're at in their code though. Even though there are these problems, we still can't just give up. And so in talking with like about this problem and several others over time. But you guys might know Kevin works in the migrations team and something that he always says is you should meet people where they are with your tooling instructions for getting from point B to point C. Do not help somebody if they are at point A. So I've been trying to come up with solutions that we can provide for people who currently use dispatching asynchronous actions in effects, for example, or use callback. So today we have added kind of our first little step in this direction towards moving all of your asynchronous handling out to libraries. I've provided this new useasync effect hook. So it's basically exactly the same as a useeffect hook, except you can hand it an asynchronous function and it provides a lot of guarantees that you don't get with the normal useeffect hook. So if you have an async action that you're dispatching, it guarantees that you will never be executing two of them at the same time and relying on which one finishes first. They're debounced in an asynchronous. They have like a async debounced behavior. Also, if you re render this component like 300 times with different IDs back to back, this is only going to make two network requests. It's going to make a request the first time it renders, and then it's going to make a request the last time, because that's the last one that was important. And the final thing it does is make sure that when you if those network requests fail, then you're going to get two different stack traces and one of them is going to point to this useasync effect call in your code, and the other one's going to be the normal one that you'd see in error reporting. Also, because there's an await there on the dispatch, there's no longer a floating promise in this code at all. There isn't a place for you to actually add error handling. The default case has been lifted out into the infrastructure. So I realized that useasync effect doesn't cover every use case under the sun, right? Not all asynchronous actions are happening in effects we have a lot of stuff that happens in use callback as well, but this is just kind of like one step in the right direction. I think some things that I've mentioned along the way and then didn't delve deeply into. So the await keyword in an async function actually does slightly alter the call stack. You can get async stack frames if you're using the await keyword properly. So this does kind of complicate some of the code examples a little bit, but the overall picture remains the same. You're still not going to get those top level synchronous stack frames even if you're using async await properly everywhere. Also, floating promises, I mentioned that they're undesirable in ways that aren't just limited to what we talked about today. So, like, another reason to have that rule on is that if ever you have a floating promise that isn't intentionally a floating promise, then you're probably missing an await. And that can be a very important problem in code. So the rule is also good for preventing that kind of race condition bug. Finally, a little fun detail about async functions, or a way to think about them, at least functions that are not async functions, but return promises. So a lot of code that is, you know, a lot of code that's written at HubSpot before the async function syntax is allowed for app code, those functions can throw synchronously or they can reject in the promise they return. So if you're trying to write proper error handling for those functions, you kind of need a try catch block and you also need a catch handler. Async functions are. Async functions are cool because they can only reject. If you write an async function and you just immediately synchronously throw, it's still going to get wrapped up in the reject call for that for the return promise. And that means that you don't have to worry about quite as many different error handling cases. Let's just find advantage for that syntax trigger. Cool. And then that brings me to end here. So I'm going to quickly scan here, see if there's some questions. Yes, we did start supporting async await at least in packages that have typeware linting on. So like, yeah, basically all packages of type aware linting on. That means that during your build we'll check to see if there are any floating promises. And yeah, if you have those rules on, then you can use async await. So there's a bend reactor lent command that you can run locally that runs eslint with the kind of the resolution logic all enabled. It runs a bit more slowly and it won't work in your editor ever, but you can at least run it locally and see all the different lines that are impacted in log output. Bend reactor lint. I can actually just stick that in the slack. So if you have a error in a catch block, are you talking about a sync or an async catch block? So generally speaking, like an error or reject that goes unhandled. Cool. Is going to get picked up by our tooling. And if you just console log it, it's not actually going to like get sent through to Sentry. So like if you have a dot catch on some code that you never expect to throw, you definitely don't want to just console log it and throw it away. You want to like. Personally I think the approach of just rethrow it if you don't plan on doing anything fancier makes a lot of sense because it guarantees that everything that is watching for error to get thrown is going to pick it up. You're not going to accidentally miss out on any default tooling behavior by trying to do the error handling yourself and like listing all the places that you're going to send the error. Like I want to print it in the console, I want to send it to the century, I want to send it to this tool. You always risk missing one. So like it's nice to just rely on the default behavior of we catch unexpected errors at the global level. Yeah, yeah. Some stuff about the result type promises are interesting in JavaScript in that they're combining the concept of the future and also like the concept of a like an object that might be an error instead of a result. And it turns out when you combine those two ideas, they kind of lose all of their nice properties. They stop being composable in certain ways. This is kind of trivia, but like, yeah, promises are an interesting design in general. Cool. Bye bye.










