The Node.js event loop got stuck because a promise rejected, and no one was listening.

This usually happens when you have asynchronous operations that might fail, and you don’t have a .catch() block or a try...catch around your await to handle those potential failures. Node.js, by default, tries to be helpful and warns you when a promise rejects without a handler, because unhandled rejections can lead to unexpected application behavior or even crashes.

Here are the most common reasons this bites you:

1. Missing .catch() on a Promise Chain: This is the classic. You’ve got a chain of .then() calls, and one of them throws an error or returns a rejected promise, but the chain ends without a .catch().

  • Diagnosis: Look for promise chains that end abruptly. Search your codebase for .then( without a subsequent .catch(.
  • Fix: Add a .catch() at the end of the chain.
    someAsyncOperation()
      .then(result => anotherAsyncOperation(result))
      .then(finalResult => console.log(finalResult))
      .catch(error => console.error('Something went wrong:', error)); // <-- This is the fix
    
    This works because the .catch() block acts as a universal handler for any rejection that occurs anywhere in the preceding promise chain.

2. Unhandled Rejection in async/await without try...catch: When you use async/await, it’s easy to forget that await will throw an error if the promise it’s waiting on rejects. Without a try...catch block, that error becomes an unhandled rejection.

  • Diagnosis: Scan async functions for await expressions that aren’t wrapped in try...catch.
  • Fix: Wrap the await calls in a try...catch block.
    async function doSomethingRisky() {
      try {
        const result = await potentiallyFailingOperation();
        console.log(result);
      } catch (error) { // <-- This is the fix
        console.error('Operation failed:', error);
      }
    }
    
    The try...catch block intercepts any error thrown by await (which is what happens when a promise rejects) and allows you to handle it gracefully.

3. Ignoring Promise Return Values: Sometimes, you might call a function that returns a promise but don’t do anything with that promise – no .then(), no .catch(), and not awaiting it. If that returned promise rejects, it’s unhandled.

  • Diagnosis: Look for function calls that are known to return promises, but the return value isn’t used.
  • Fix: Either assign the promise to a variable and attach a .catch(), or await it.
    // Bad:
    // readFileAsync('config.json');
    
    // Good (using .catch):
    readFileAsync('config.json').catch(err => console.error('Failed to read config:', err));
    
    // Good (using async/await):
    async function loadConfig() {
      try {
        const config = await readFileAsync('config.json');
        // ... use config
      } catch (err) {
        console.error('Failed to read config:', err);
      }
    }
    
    By actively managing the promise (either through chaining or await), you ensure that its eventual outcome, success or failure, is accounted for.

4. Rejections from Event Emitters (e.g., EventEmitter.once): If you’re using event emitters and a listener attached via once or on throws an error or returns a rejected promise, it can sometimes lead to unhandled rejections if not properly managed. While less common for direct promise rejections, the underlying principles apply.

  • Diagnosis: Examine how you’re attaching listeners to EventEmitters, especially if those listeners involve asynchronous operations.
  • Fix: Ensure any asynchronous logic within event listeners is also wrapped in error handling.
    const myEmitter = new EventEmitter();
    
    myEmitter.once('data', async () => {
      try {
        await processData();
      } catch (error) { // <-- Error handling within the listener
        console.error('Error processing data:', error);
      }
    });
    
    This prevents errors originating from within the event handler itself from bubbling up as unhandled exceptions.

5. Third-Party Libraries Not Handling Their Own Rejections: You might be using a library that returns promises, but the library itself might have a bug where it doesn’t handle rejections internally, leading to unhandled rejection warnings from your application’s perspective.

  • Diagnosis: When the warning points to code within a node_modules directory, investigate the library’s API. Check its documentation for error handling patterns.
  • Fix: Wrap calls to the problematic library functions with your own .catch() or try...catch.
    const library = require('some-unreliable-library');
    
    async function useLibrary() {
      try {
        const result = await library.doSomething();
        console.log(result);
      } catch (error) { // <-- Your safeguard
        console.error('Error from external library:', error);
      }
    }
    
    This creates a defensive layer around external code, ensuring that even if the library fails to catch its own errors, your application doesn’t crash.

6. Implicit Rejections in Synchronous Code: While less direct, sometimes synchronous code that should throw an error doesn’t, and instead returns an unexpected value that a subsequent promise chain then tries to process, leading to a rejection downstream.

  • Diagnosis: Trace the data flow backward from the point of rejection. Look for synchronous functions that might return invalid states or undefined when they should throw.
  • Fix: Add explicit error throwing in synchronous code where appropriate.
    function validateInput(data) {
      if (!data || typeof data.value === 'undefined') {
        throw new Error('Invalid input: missing value'); // <-- Explicit throw
      }
      return data;
    }
    
    async function processValidatedData() {
      const input = getInput(); // This might return invalid data
      const validated = validateInput(input); // If validateInput doesn't throw, validated might be bad
      const result = await process(validated);
      console.log(result);
    }
    
    By making synchronous code more robust with explicit error throwing, you prevent invalid states from propagating into asynchronous operations and causing unexpected rejections.

Once all these are fixed, the next error you’ll likely encounter is ERR_QUIC_SOCKET_CLOSED if you’re using QUIC/HTTP3 and the underlying connection is abruptly terminated due to a previous, unhandled network issue.

Want structured learning?

Take the full Nodejs course →