Fixing 'Message Port Closed Before a Response Was Received' Errors
Diagnose and fix the 'The message port closed before a response was received' error in MV3 extensions — root causes, step-by-step repair, and verification via DevTools.
Diagnose and fix the 'The message port closed before a response was received' error in MV3 extensions — root causes, step-by-step repair, and verification via DevTools.
The error "The message port closed before a response was received" is the single most common runtime failure in Manifest V3 messaging. It appears in the popup’s console as an uncaught Promise rejection (or silently resolves to undefined) and almost always means one thing: the service worker’s onMessage listener closed the channel before calling sendResponse. This guide is part of the message passing architecture overview and gives you the exact root causes, the mechanical fixes, and a DevTools procedure to verify each fix.
Chrome’s message channel between sendMessage caller and onMessage listener has a strict lifetime rule: the channel stays open for exactly the duration of the synchronous execution of the onMessage listener callback. When that callback returns — or when it returns a falsy value — the channel is torn down. If you have not called sendResponse yet, it becomes a no-op and the caller’s sendMessage Promise resolves to undefined with no error in Chrome 110 and earlier, or rejects with the port-closed error in Chrome 111+.
Timeline (broken pattern):
────────────────────────────────────────────────────────────
Popup chrome.runtime Service Worker
│ │ │
├─sendMessage()───▶│ │
│ ├──onMessage fires ────▶│
│ │ ├─ async work starts
│ │ │
│ │ listener returns │
│ │◀─ undefined ──────────┤ ← channel closed here
│ │ │
│◀─ undefined ─────┤ ├─ sendResponse() ← no-op
│ (or rejection) │ │
────────────────────────────────────────────────────────────
There are three distinct root causes that produce this error. Fixing the wrong one wastes time.
return true for async handlersThis is the most common cause. The listener does async work (a chrome.storage read, a fetch, even a Promise.resolve()) but forgets to return true from the synchronous part of the listener.
1// BROKEN — async work but no return true
2chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
3 if (message.type === "LOAD_DATA") {
4 chrome.storage.local.get("data").then((result) => {
5 sendResponse(result); // too late — channel already closed
6 });
7 // returns undefined implicitly → channel torn down immediately
8 }
9});
Execution context: Service worker background thread. The .then() callback runs in a microtask after the listener has already returned, which is after Chrome has already closed the channel.
Fix: Return true synchronously from the listener body, before any async code.
1// FIXED — return true before yielding
2chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
3 if (message.type === "LOAD_DATA") {
4 chrome.storage.local.get("data").then((result) => {
5 sendResponse(result); // now arrives while channel is still open
6 });
7 return true; // ← signals Chrome to keep the channel open
8 }
9});
Execution context: Service worker. return true must appear in the synchronous body of the addListener callback, not inside a .then(), async function, or IIFE. Chrome evaluates the return value of the callback synchronously at the point the function frame exits.
return true inside a conditional that does not always executeA subtler variant: return true is present but nested inside an if block, and for some message types the branch is not taken. The listener then returns undefined for those types, closing the channel before sendResponse can fire.
1// BROKEN — return true only for one branch
2chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
3 if (message.type === "ASYNC_ACTION") {
4 doAsyncWork().then((r) => sendResponse(r));
5 return true; // ✓ correct for this branch
6 }
7 // ALL other message types fall through and return undefined
8 // If any of them call sendResponse asynchronously, it will be a no-op
9 doSomethingAsync().then((r) => sendResponse(r)); // ✗ channel already closed
10});
Execution context: Service worker. The rule is: if ANY branch of your listener calls sendResponse asynchronously, return true must appear in that branch. If ALL branches call sendResponse synchronously, return true is not needed. Mixed-mode listeners are the most error-prone.
Fix: Add return true to every branch that does async work, and call sendResponse synchronously in branches that do not.
1// FIXED — explicit control flow per branch
2chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
3 if (message.type === "ASYNC_ACTION") {
4 doAsyncWork().then((r) => sendResponse(r));
5 return true; // async branch — keep channel open
6 }
7
8 // Synchronous branch — respond immediately, no return true needed
9 const result = lookupSyncData(message.key);
10 sendResponse({ ok: true, result });
11 // returning undefined here is fine because sendResponse already fired
12});
Execution context: Service worker. This pattern keeps each branch self-contained and avoids silent no-ops. TypeScript’s exhaustive union checks help catch unhandled message types at compile time.
Declaring the listener itself as async causes it to return a Promise rather than true. Chrome does not treat a returned Promise as the keep-open signal — it only checks for the boolean true. The Promise resolves to true eventually, but by that time Chrome has already evaluated the synchronous return value (the Promise object, which is truthy but not strictly === true in the runtime check).
1// BROKEN — async listener returns a Promise, not true
2chrome.runtime.onMessage.addListener(async (message, sender, sendResponse) => {
3 const data = await chrome.storage.local.get("key"); // yields here
4 sendResponse(data); // channel already closed
5 // The implicit return from an async function is a Promise, not true
6});
Execution context: Service worker. Chrome’s onMessage spec explicitly requires the boolean literal true as the return value — a truthy Promise does not satisfy the check in Chromium’s implementation.
Fix: Keep the listener synchronous. Move the async work into an IIFE that you start before returning true.
1// FIXED — IIFE for async work, synchronous return true
2chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
3 (async () => {
4 const data = await chrome.storage.local.get("key");
5 sendResponse(data);
6 })();
7 return true; // synchronous, evaluates before IIFE resolves
8});
Execution context: Service worker. The IIFE is launched and immediately returns control to the listener body. The listener then returns true synchronously, keeping the channel open. When the IIFE resolves, sendResponse fires into the still-open channel.
Popup chrome.runtime Service Worker
│ │ │
├─sendMessage()───▶│ │
│ ├──onMessage fires ────▶│
│ │ ├─ IIFE launched (async)
│ │ listener returns │
│ │◀─ true ───────────────┤ ← channel stays open
│ │ ├─ await storage.get()
│ │ ├─ sendResponse(data)
│◀─ { data } ──────┤◀──────────────────────┤ ← response delivered
│ │ │
true keeps the channel open. An async listener (returning a Promise) does NOT. This is the source of most bugs found in MV2-to-MV3 migrations.Promise directly from the listener as an alternative to return true + sendResponse. The Promise’s resolved value becomes the response. This is cleaner but Chrome-incompatible without a polyfill — use the IIFE + return true pattern for code that must run on both.return true is required, async listeners do not work. Safari also enforces a shorter implicit timeout before it closes channels if sendResponse is never called; do not rely on open channels surviving more than a few seconds of async work.Use the following DevTools procedure to confirm each fix:
return true. If the breakpoint fires, the listener returned true successfully.1chrome.runtime.sendMessage({ type: "LOAD_DATA" }).then(console.log)
undefined, return true did not execute.return true — and confirm the console shows "Unchecked runtime.lastError: The message port closed before a response was received". This proves the test is sensitive to the bug.return true and re-run step 3. The error should be gone and the correct data object logged.For async listeners misidentified as fixed: add console.log("listener return value:", /* inspect */) just before each return statement to confirm the exact value Chrome receives.
return true need to appear in every addListener callback I register?Only in callbacks where sendResponse is called asynchronously. If your listener always calls sendResponse synchronously (no await, no .then()), you do not need return true. But for any listener that might call sendResponse in the future after an async operation, return true is required.
return true?Not on Chrome. Chrome’s onMessage specification only checks for the strict boolean true. A Promise that eventually resolves to true does not satisfy the check. Firefox does accept a returned Promise, but targeting Chrome + Firefox means using the IIFE + return true pattern exclusively.
addListener calls for the same event?Each listener is evaluated independently. Chrome keeps the channel open if ANY registered listener returns true. However, only one sendResponse call is honoured — the first one wins. If you split logic across multiple listeners, ensure exactly one of them calls sendResponse and that it returns true.
sendResponse?No. The channel closes as soon as sendResponse is called or when the listener that returned true is garbage-collected. For ongoing communication after an initial request, switch to a long-lived port with chrome.runtime.connect — see long-lived ports vs one-time messages.
sendMessage with a persistent port.Chrome vs Firefox vs Safari Manifest V3 API compatibility reference — namespace differences, Promise vs callback, storage quotas, declarativeNetRequest, and the webextension-polyfill.
Use chrome.scripting in Manifest V3 to dynamically inject JavaScript and CSS — executeScript, registerContentScripts, world isolation, and activeTab patterns.
Persist and synchronise extension state across devices with chrome.storage.sync — quotas, async patterns, change events, encryption and cross-browser adapters for Manifest V3.
Master chrome.declarativeNetRequest static, dynamic, and session rulesets in MV3 — rule schema, modifyHeaders, redirect, block actions, quota limits, and getMatchedRules debugging.