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();
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...");
}
"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);
}
}
"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.
}
// ✅ RIGHT: Parachute inside the callback
button.addEventListener('click', () => {
try {
throw new Error("Click failed!"); // Caught safely!
} catch (e) {
console.error("Safe Landing:", e.message);
}
});
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.


Comments
Post a Comment