DEV Community

Cover image for The Secret Life of JavaScript: The Rejection
Aaron Rose
Aaron Rose

Posted on

The Secret Life of JavaScript: The Rejection

Why async errors bypass try/catch, and how to fix them.


Timothy felt invincible. He had learned the mechanics of Stack Unwinding. He had placed a strategic try/catch boundary at the top of his application. He was a master of disaster recovery.

Then, he wrote a new network request.

function loadDashboard() {
    try {
        // Initiating a background network request
        fetch('/api/corrupted-data'); 
        console.log("Dashboard loading...");
    } catch (error) {
        console.error("Safe Landing:", error.message);
    }
}

loadDashboard();

Enter fullscreen mode Exit fullscreen mode

Timothy ran the code. The console printed Dashboard loading....

Two seconds later, a massive red error filled the screen: UnhandledPromiseRejection: Failed to fetch.

Timothy stared at the screen. The application had crashed. "But... I put it inside a try/catch," he stammered. "Why didn't the parachute deploy? Where was the boundary?"

Margaret walked over, holding her dry-erase marker. "The boundary worked perfectly, Timothy," she said. "The problem is that your parachute deployed on Tuesday, and the plane crashed on Thursday."

The Ghost Stack

Margaret went to the whiteboard and drew the Call Stack.

"Let's trace time," she said. "At T=0, loadDashboard is pushed onto the stack. The try block begins."

"At T=1, you call fetch(). It hands the network request off to the browser and instantly returns a pending Promise. It does not wait."

"At T=2, the try block finishes. There was no error. The engine assumes everything is fine. loadDashboard is popped off the stack and destroyed."

Margaret erased the entire Call Stack. The board was empty.

"At T=2000, the network request fails," she continued. "The browser pushes a rejection into the Event Loop. The engine looks for the Call Stack to unwind." She pointed to the empty whiteboard. "But the stack is gone. Your catch block was destroyed two seconds ago."

"So the error just... floats?" Timothy asked.

"It is an Unhandled Promise Rejection," Margaret said. "It is an error with no home. And in modern Node.js and browsers, an unhandled rejection instantly terminates the process. It is a fatal blow."

The Chain Link

"If the Call Stack is gone, how do we catch it?" Timothy asked.

"If you are dealing with Promises, you must attach the parachute directly to the Promise itself," Margaret said. She wrote on the board:

function loadDashboard() {
    fetch('/api/corrupted-data')
        .then(data => console.log("Data loaded!"))
        .catch(error => console.error("Safe Landing:", error.message));

    console.log("Dashboard loading...");
}

Enter fullscreen mode Exit fullscreen mode

"A Promise is an object that holds its own future," Margaret explained. "When you use .catch(), you are attaching an error boundary directly to that object. When the network fails at T=2000, the Promise doesn't look at the empty Call Stack. It simply routes the failure into its own .catch() method."

Timothy looked at the code. "Notice the execution order," he observed. "It prints Dashboard loading... immediately, and handles the error later. It is non-blocking."

The Time Machine (async/await)

"Exactly," Margaret said. "But what if you want to use your try/catch syntax instead of .catch() chains? You have to freeze time."

She rewrote the function, adding two powerful keywords.

async function loadDashboard() {
    try {
        console.log("Dashboard loading..."); // Moved up!

        // We freeze the execution context!
        await fetch('/api/corrupted-data'); 

        console.log("Data loaded successfully!");
    } catch (error) {
        console.error("Safe Landing:", error.message);
    }
}

Enter fullscreen mode Exit fullscreen mode

"Notice what changed," Margaret pointed out. "We had to move the console.log above the fetch. When you use await, the engine suspends the entire function—including its try/catch boundaries—and stores them safely in memory."

"Two seconds later, when the fetch fails, the Promise rejects. The engine wakes up loadDashboard, restores the try/catch boundary exactly as it was, and converts the rejection into a standard throw."

Timothy watched the console. The red UnhandledPromiseRejection was gone. The console calmly printed: Safe Landing: Failed to fetch.

The Rules of Asynchrony

"This is the golden rule of asynchronous architecture," Margaret said, putting the cap back on her marker.

"Synchronous errors travel down the Call Stack to find a try/catch."

"Asynchronous errors travel down the Promise Chain to find a .catch()."

"And async/await is the magic bridge that connects the two. It pauses the stack so the parachute is still there when the plane finally goes down."

Senior Tip: Callbacks and Event Listeners
Promises aren't the only ghost stacks. If you use setTimeout or a DOM Event Listener (button.addEventListener), the callback executes in a brand new, empty stack later in time. Wrapping the setup in a try/catch will not catch errors inside the callback.

 // ❌ WRONG: The parachute is deployed during setup, long before the click.
 try {
     button.addEventListener('click', () => {
         throw new Error("Click failed!"); // This crashes the app!
     });
 } catch (e) {
     // This catch block is long gone by the time the user clicks.
 }
Enter fullscreen mode Exit fullscreen mode
// ✅ RIGHT: Parachute inside the callback
button.addEventListener('click', () => {
    try {
        throw new Error("Click failed!"); // Caught safely!
    } catch (e) {
        console.error("Safe Landing:", e.message);
    }
});
Enter fullscreen mode Exit fullscreen mode

Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (4)

Collapse
 
bhavin-allinonetools profile image
Bhavin Sheth

This is a really good explanation. I remember hitting this exact issue when I first used fetch. I had try/catch everywhere but still got UnhandledPromiseRejection and couldn’t understand why.

The biggest lesson for me was simple: try/catch only works with await. If you don’t use await, you must use .catch().

After that, I started always writing:

try {
  const res = await fetch(url);
} catch (e) {
  // handle
}
Enter fullscreen mode Exit fullscreen mode

It made my async code much safer and easier to debug. This is something every JS developer learns the hard way.

Collapse
 
aaron_rose_0787cc8b4775a0 profile image
Aaron Rose

❤✨🙏

Collapse
 
matthewhou profile image
Matthew Hou

The async error handling model in JavaScript really is its own mental puzzle. The thing that trips most people up is that Promise rejections don't work like synchronous exceptions — they can silently disappear if you don't have a handler. The global unhandledRejection event was an afterthought, not a design feature.

What shifted my thinking was treating every Promise chain as a potential data flow that needs an explicit failure boundary, not just a "there might be an error here" annotation.

Collapse
 
aaron_rose_0787cc8b4775a0 profile image
Aaron Rose

💯✨🙏