Skip to main content

Fixing 'The message port closed before a response was received' in Chrome Extensions

 If you are developing a Chrome Extension using Manifest V3, you have almost certainly encountered this error in your extension management console:

Unchecked runtime.lastError: The message port closed before a response was received.

This error is frustrating because it often occurs silently in the background, causing your popup or content script to hang indefinitely while waiting for a response that never comes. It usually happens when you try to perform an asynchronous operation (like a fetch request or database lookup) inside the chrome.runtime.onMessage listener.

Here is the root cause analysis, the technical breakdown of the message channel lifecycle, and the robust solution to fix it permanently.

The Root Cause: Synchronous Expectations in an Async World

To understand the error, you must understand how the Chrome Messaging API manages memory and resources.

When a message is sent via chrome.runtime.sendMessage, Chrome opens a communication port. It triggers your event listeners in the background script (Service Worker). Once your event listener function finishes execution, Chrome's default behavior is to immediately close the port to free up resources.

The problem arises when you introduce asynchronous JavaScript.

If you trigger a fetch request or use await, your JavaScript function essentially pauses execution of that specific logic block, but the function itself returns (usually returning a Promise). Chrome sees the function return, assumes the work is done, and kills the connection. When your asynchronous operation finally finishes 200ms later and tries to call sendResponse(), the "line is dead."

The Anti-Pattern: What Not To Do

The most common mistake is making the listener callback async.

In modern JavaScript, we love async/await. However, passing an async function to chrome.runtime.onMessage.addListener is dangerous if you intend to use sendResponse.

Broken Code Example

// background.js

// ❌ THIS WILL FAIL
chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
  if (request.action === "fetchUserData") {
    try {
      // The listener function returns a Promise immediately here
      // Chrome sees the return, and closes the port.
      const response = await fetch(`https://api.example.com/users/${request.userId}`);
      const data = await response.json();
      
      // This runs too late. The port is already closed.
      sendResponse({ status: "success", data }); 
    } catch (error) {
      sendResponse({ status: "error", message: error.message });
    }
  }
});

Because the function is async, it implicitly returns a Promise. Chrome's messaging API does not wait for this Promise to resolve by default. It sees a return value that is not true (it's a Promise object), and it closes the channel.

The Fix: Returning true

To tell Chrome, "Wait! I have asynchronous work pending," you must explicitly return true synchronously from the event listener.

This boolean flag signals the Chrome runtime to keep the message channel open until sendResponse is explicitly called, regardless of how long the operation takes.

The Correct Implementation

We need to decouple the asynchronous logic from the synchronous listener return. We do this by calling a separate async handler but returning true immediately.

// background.js

/**
 * Valid Manifest V3 Background Script
 */

// 1. Define the asynchronous logic in a separate function
const handleUserFetch = async (userId) => {
  try {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    
    const data = await response.json();
    return { status: "success", data };
  } catch (error) {
    console.error("Fetch failed:", error);
    return { status: "error", message: error.message };
  }
};

// 2. The Listener
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  
  if (request.action === "fetchUserData") {
    // Call the async function. 
    // We use .then() to handle the result and trigger sendResponse
    handleUserFetch(request.userId).then((payload) => {
      sendResponse(payload);
    });

    // CRITICAL: Return true synchronously to keep the channel open
    return true; 
  }
  
  // If the message isn't handled here, we return false (or undefined)
  // so other listeners can potentially handle it.
  return false; 
});

Why This Works

  1. Synchronous Execution: The addListener callback executes immediately.
  2. Promise Initiation: handleUserFetch is invoked. It starts the network request in the background.
  3. Signal to Chrome: The line return true; executes immediately after invoking the fetch. Chrome receives this signal and flags the message port as "Async Response Pending."
  4. Completion: When the Promise resolves, .then() fires, calls sendResponse(payload), and Chrome closes the port gracefully.

Handling Edge Cases: Disconnected Extensions

While return true fixes the specific runtime error, you must also handle the client-side (Content Script or Popup) correctly. If the background script crashes or the extension is updated/reloaded while a message is pending, the connection is severed.

On the receiving end (e.g., inside a React component or content script), you should always check chrome.runtime.lastError.

// content-script.js

const getUserData = (userId) => {
  chrome.runtime.sendMessage(
    { action: "fetchUserData", userId },
    (response) => {
      // 1. Check for framework-level errors (Port closed, extension reloaded)
      if (chrome.runtime.lastError) {
        console.error("Runtime error:", chrome.runtime.lastError.message);
        return;
      }

      // 2. Check for application-level errors (404, 500)
      if (response && response.status === "error") {
        console.error("App error:", response.message);
        return;
      }

      // 3. Success
      if (response) {
        console.log("User Data:", response.data);
      }
    }
  );
};

Advanced: Using Promise Wrappers (The Modern Way)

The callback style of sendMessage leads to "callback hell." In modern Extension development (ES2022+), it is cleaner to wrap sendMessage in a Promise. This allows you to use await in your UI components cleanly.

Here is a utility helper you can copy-paste into your project:

// utils/messaging.js

/**
 * Wraps chrome.runtime.sendMessage in a Promise.
 * Handles runtime errors automatically.
 */
export const sendAsyncMessage = (payload) => {
  return new Promise((resolve, reject) => {
    chrome.runtime.sendMessage(payload, (response) => {
      // Check for Chrome runtime errors
      if (chrome.runtime.lastError) {
        reject(new Error(chrome.runtime.lastError.message));
      } else {
        resolve(response);
      }
    });
  });
};

Usage in a React Component:

import { sendAsyncMessage } from './utils/messaging';

const handleButtonClick = async () => {
  try {
    const data = await sendAsyncMessage({ action: "fetchUserData", userId: 123 });
    console.log("Received:", data);
  } catch (err) {
    // Catches both "Port closed" errors and logic errors
    console.error("Communication failed:", err);
  }
};

Summary

The "message port closed" error is strictly a lifecycle management issue. Chrome's Service Workers are ephemeral; they want to shut down as fast as possible.

  1. Do not make your onMessage listener async.
  2. Do decouple your logic into a separate async function.
  3. Always return true synchronously if you plan to call sendResponse later.

By following this pattern, you ensure your extension communicates reliably, regardless of network latency or operation complexity.