cyberangles blog

Why Node Fetch Catch Block Isn't Working: Troubleshooting Unhandled Promise Rejection with Invalid JSON Response

As a Node.js developer, you’ve likely used the fetch API (or its polyfill) to make HTTP requests. It’s a powerful, promise-based tool for handling network calls, and you probably rely on .catch() blocks or try/catch with async/await to handle errors. But what happens when your catch block doesn’t catch an error, leaving you with an "UnhandledPromiseRejectionWarning"?

One common culprit is invalid JSON responses. Unlike network errors (e.g., no internet), invalid JSON can slip through fetch’s initial error handling, leading to unexpected rejections. In this blog, we’ll demystify why this happens, how to diagnose it, and how to fix it for good.

2025-11

Table of Contents#

  1. Understanding Node Fetch Basics
  2. The "Catch Block Isn’t Working" Scenario: A Common Pitfall
  3. Why Invalid JSON Causes Unhandled Rejections
  4. Step-by-Step Troubleshooting
  5. Best Practices to Prevent Unhandled Rejections
  6. Conclusion
  7. References

1. Understanding Node Fetch Basics#

Before diving into the problem, let’s recap how fetch works in Node.js. The fetch API returns a Promise that resolves to a Response object when the request completes (regardless of the HTTP status code, e.g., 404, 500). This is a critical detail: fetch only rejects on network failures (e.g., DNS errors, no connection), not on HTTP error statuses or invalid response bodies.

To extract data from the Response object, you typically use methods like response.json(), which parses the response body as JSON. However, response.json() itself returns a new Promise. If the response body is not valid JSON, this promise rejects, which can lead to unhandled rejections if not properly caught.

2. The "Catch Block Isn’t Working" Scenario: A Common Pitfall#

Let’s start with a familiar code snippet that seems correct but fails silently:

// Broken code: Catch block doesn't catch invalid JSON errors
fetch("https://api.example.com/data")
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    return response.json(); // Parsing JSON here
  })
  .then((data) => {
    console.log("Success:", data);
  })
  .catch((error) => {
    console.error("Fetch error:", error); // This catch block may not trigger!
  });

At first glance, this looks solid: we check for HTTP errors with response.ok, parse the JSON, and have a .catch() at the end. But if the API returns invalid JSON (e.g., a malformed string like { "name": "John", } with a trailing comma), you’ll see an "UnhandledPromiseRejectionWarning" instead of the expected "Fetch error" log.

Why? The fetch promise resolves successfully (network request worked), so the first .then() runs. Then response.json() is called, which returns a rejected promise (due to invalid JSON). But this rejection isn’t caught by the final .catch() because it occurs in a nested promise chain.

3. Why Invalid JSON Causes Unhandled Rejections#

To understand this, let’s break down the promise chain step by step:

Promise Flow:#

  1. fetch(...) returns a promise (Promise A).

    • If the network fails, Promise A rejects, and the final .catch() catches it.
    • If the network succeeds, Promise A resolves to a Response object.
  2. The first .then() receives the Response and calls response.json(), which returns a new promise (Promise B).

    • Promise B resolves if the response is valid JSON.
    • Promise B rejects if the response is invalid JSON (e.g., syntax error).
  3. If Promise B rejects and there’s no .catch() attached to it, the rejection propagates up the chain. However, if the final .catch() is only attached to Promise A, it may not catch rejections from Promise B—especially in older Node.js versions or if the chain is broken.

Key Insight:#

Unhandled rejections occur because the error is thrown in Promise B (the response.json() call), not in Promise A (the initial fetch). The final .catch() is designed to catch errors from Promise A and its direct chain, but if Promise B rejects without a local .catch(), it becomes unhandled.

4. Step-by-Step Troubleshooting#

Let’s diagnose and fix the invalid JSON issue with a systematic approach.

Step 1: Identify the Source of the Error#

First, confirm whether the error is from fetch or response.json(). Add logs to isolate the problem:

fetch("https://api.example.com/data")
  .then((response) => {
    console.log("Fetch resolved with response:", response); // Log the response
    return response.json();
  })
  .then((data) => console.log("Data parsed:", data))
  .catch((error) => console.error("Caught error:", error));
  • If you see "Fetch resolved with response" in the logs, the network request succeeded, and the error is likely from response.json().
  • If you don’t see that log, the error is from fetch (network failure).

Step 2: Inspect the Raw Response Body#

If the error is from response.json(), log the raw response body to check for invalid JSON:

fetch("https://api.example.com/data")
  .then((response) => {
    // Log raw response text before parsing
    return response.text().then((text) => {
      console.log("Raw response body:", text); // Check for invalid JSON here
      return JSON.parse(text); // Explicitly parse to trigger error
    });
  })
  .then((data) => console.log("Data:", data))
  .catch((error) => console.error("Error:", error));

Now you’ll see the raw text, which may reveal issues like:

  • Trailing commas (e.g., { "key": "value", }).
  • HTML error pages (e.g., a 500 error with <html>...</html> instead of JSON).
  • Missing brackets/quotes (e.g., { key: "value" } instead of { "key": "value" }).

Step 3: Add Error Handling for response.json()#

The root fix is to explicitly handle rejections from response.json(). There are two common approaches:

Approach A: Use .catch() on the response.json() Promise#

Attach a .catch() directly to the response.json() call to handle parsing errors:

fetch("https://api.example.com/data")
  .then((response) => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    // Handle JSON parsing errors here
    return response.json().catch((jsonError) => {
      throw new Error(`Failed to parse JSON: ${jsonError.message}`);
    });
  })
  .then((data) => console.log("Success:", data))
  .catch((error) => console.error("Handled error:", error)); // Now catches JSON errors

Approach B: Use async/await with try/catch (Cleaner!)#

async/await makes error handling more linear. Wrap both fetch and response.json() in a try/catch block:

async function fetchData() {
  try {
    const response = await fetch("https://api.example.com/data");
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    // Parse JSON with try/catch to handle invalid JSON
    try {
      const data = await response.json();
      console.log("Success:", data);
      return data;
    } catch (jsonError) {
      throw new Error(`JSON parse failed: ${jsonError.message}`);
    }
  } catch (error) {
    console.error("Handled error:", error); // Catches all errors: fetch + JSON
  }
}
 
fetchData();

This ensures both network errors (from fetch) and parsing errors (from response.json()) are caught.

Step 4: Validate JSON Before Parsing#

For extra safety, validate the response content type and structure before parsing. For example:

async function fetchData() {
  try {
    const response = await fetch("https://api.example.com/data");
    
    // Check if response is JSON before parsing
    const contentType = response.headers.get("content-type");
    if (!contentType || !contentType.includes("application/json")) {
      throw new Error("Response is not JSON");
    }
 
    const data = await response.json();
    return data;
  } catch (error) {
    console.error("Error:", error);
  }
}

5. Best Practices to Prevent Unhandled Rejections#

To avoid invalid JSON-related unhandled rejections, follow these practices:

1. Always Handle response.json() Errors#

Whether using .then() chains or async/await, explicitly handle errors from response.json(). Never assume the response is valid JSON.

2. Use async/await for Readability#

async/await with try/catch makes it easier to handle nested promise errors (like response.json()) in a single block.

3. Validate HTTP Status Codes#

fetch doesn’t reject on 4xx/5xx statuses. Always check response.ok (or response.status) to throw errors for non-2xx statuses:

if (!response.ok) {
  throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
}

4. Log Raw Responses for Debugging#

When debugging, log the raw response body (with response.text()) to inspect invalid JSON or unexpected content.

5. Wrap Fetch in a Utility Function#

Create a reusable fetch wrapper to standardize error handling:

async function safeFetch(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    
    const contentType = response.headers.get("content-type");
    if (!contentType?.includes("application/json")) {
      throw new Error("Response is not JSON");
    }
 
    return await response.json();
  } catch (error) {
    console.error("safeFetch error:", error);
    throw error; // Re-throw to let callers handle if needed
  }
}
 
// Usage
safeFetch("https://api.example.com/data")
  .then((data) => console.log("Data:", data))
  .catch((error) => console.error("Final error:", error));

6. Conclusion#

The "Node fetch catch block isn’t working" issue often stems from misunderstanding how fetch and response.json() work together. Remember:

  • fetch resolves to a Response object even for non-2xx statuses (it only rejects on network failures).
  • response.json() returns a promise that rejects if the body is invalid JSON.
  • Unhandled rejections occur when response.json() rejects and no .catch() (or try/catch) is attached to that specific promise.

By explicitly handling errors at each step of the promise chain—especially for response.json()—and validating responses, you can eliminate unhandled rejections and build more robust Node.js applications.

7. References#