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
- Synchronous Execution: The
addListenercallback executes immediately. - Promise Initiation:
handleUserFetchis invoked. It starts the network request in the background. - 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." - Completion: When the Promise resolves,
.then()fires, callssendResponse(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.
- Do not make your
onMessagelistenerasync. - Do decouple your logic into a separate async function.
- Always
return truesynchronously if you plan to callsendResponselater.
By following this pattern, you ensure your extension communicates reliably, regardless of network latency or operation complexity.