Skip to content

Fix "The message port closed before a response was received." in Chrome extensions

6 min readModerok team

Why Chrome throws "The message port closed before a response was received.", how return true and async onMessage listeners cause it, and the fix.

If your popup or content script fires a chrome.runtime.sendMessage and then hangs forever, and the service worker console shows Unchecked runtime.lastError: The message port closed before a response was received., the cause is almost always the same: your onMessage listener did asynchronous work but the message channel was already closed by the time it called sendResponse. The one-line fix is to return true synchronously from the listener so Chrome keeps the channel open until you respond. The rest of this post explains why that single line matters, why an async listener quietly breaks it, and how to stop the error from silently killing a feature in production.

What the error actually means

Chrome extension messaging is request/response over a short-lived port. When a sender calls chrome.runtime.sendMessage(msg, callback) (or awaits the promise form), Chrome opens a channel, delivers msg to every onMessage listener, and waits for one of them to call sendResponse. The catch is that Chrome decides whether to keep waiting based on what your listener returns synchronously:

  • Return true: Chrome keeps the channel open and sendResponse stays valid until you call it.
  • Return false or nothing: Chrome assumes you already responded (or never will) and closes the channel as soon as the listener function returns.

"The message port closed before a response was received." is what the sender sees when it was still waiting but the channel closed underneath it. The receiver returned a falsy value, the listener function finished, Chrome tore down the port, and then your .then() or await finally resolved and called sendResponse into a port that no longer exists. sendResponse becomes a no-op. On the sender side, Chrome invokes your callback with response === undefined and sets chrome.runtime.lastError to the "message port closed" message, which is exactly what produces the "Unchecked runtime.lastError" warning if you never read it. If you used the promise form of sendMessage instead, the promise rejects with that same message.

The pattern that breaks

This is the classic version. It looks correct because the async work is right there, but nothing tells Chrome to wait:

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === "GET_TOKEN") {
    getTokenFromStorage().then((token) => {
      sendResponse({ token }); // runs, but the port is already closed
    });
    // listener returns undefined here -> Chrome closes the channel now
  }
});

The listener body runs to the end, returns undefined, and Chrome closes the port immediately. Your getTokenFromStorage() promise resolves a few milliseconds later and calls sendResponse into nothing. The fix is one line:

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === "GET_TOKEN") {
    getTokenFromStorage().then((token) => {
      sendResponse({ token });
    });
    return true; // keep the channel open for the async sendResponse
  }
});

return true must be the synchronous return value of the listener. You cannot await first and then return true, because by the time the await resolves the function has already returned once.

Why an async listener silently fails

The second trap catches people who reach for modern syntax. It seems natural to make the listener async so you can await inside it:

// Fragile: depends on Chrome version
chrome.runtime.onMessage.addListener(async (msg, sender, sendResponse) => {
  const token = await getTokenFromStorage();
  sendResponse({ token });
});

An async function always returns a Promise, and a Promise is truthy but it is not the literal value true. For most of MV3's history Chrome recognized only a synchronous literal true as the keep-open signal, so an async listener let the channel close the moment the function yielded at its first await, and the later sendResponse landed on a dead port. Firefox has long supported returning a Promise from onMessage and resolves it as the response, which is why code copied from a Firefox add-on or a cross-browser tutorial can work there and fail in Chrome.

Chrome has since been adding Promise-return support for onMessage, but the rollout has not been clean: it shipped, was reverted after breaking onMessageExternal responses, and has been re-rolled since, so whether a returned Promise is honored depends on the Chrome version and channel your user is running. That is the worst kind of thing to build on: it works on your machine and fails for some fraction of your install base. Use a plain (non-async) listener that returns true and delegates to an inner async function, which behaves the same on every Chrome version and in Firefox:

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  handle(msg).then(sendResponse).catch((error) => {
    sendResponse({ error: String(error) });
  });
  return true;
});

async function handle(msg) {
  if (msg.type === "GET_TOKEN") {
    return { token: await getTokenFromStorage() };
  }
  return { error: "unknown message" };
}

Note the .catch: if your async work throws and you never call sendResponse, the port stays open and the sender still eventually gets "message port closed" when the channel is finally torn down. Respond on every path, including errors.

Return true only when you will respond

There is a subtler version of this bug that produces the same warning even when a different feature is at fault. If any listener returns true but then never calls sendResponse (a stale handler, a branch you forgot, a message it does not recognize), Chrome holds the channel open until the sender's context goes away, and the teardown surfaces as the same "message port closed" message. Two rules keep this clean:

  • Return true only from the code paths that will actually call sendResponse.
  • For a message you are ignoring, return false or nothing so Chrome closes the channel right away.

This is the opposite failure mode from "Could not establish connection. Receiving end does not exist.", which means there was no listener at all (a common case when messaging a tab whose content script was never injected). If you are chasing that variant instead, see our post on catching chrome.runtime.lastError before it silently breaks your extension, which walks through the receiving-end case in detail.

The MV3 twist: the service worker can die mid-response

Even a perfectly correct return true handler can produce this error in Manifest V3 for a reason that has nothing to do with your messaging code. If the receiving side is a background service worker and your async work takes long enough (a slow network call, a big storage read), the worker can be terminated before sendResponse runs. When the worker goes down, its open ports close, and the sender sees "message port closed." Sending a message to a service worker does reset its idle timer, but a single long-running handler can still outlive the worker under memory pressure or the hard lifetime limits. If you see this error only on slow machines or slow networks, suspect worker termination rather than a missing return true, and read why your MV3 service worker keeps stopping. For genuinely long or streaming work, a long-lived chrome.runtime.connect port gives you clearer lifecycle signals (port.onDisconnect) than one-shot sendMessage.

Make the failures visible instead of silent

The reason this bug is so expensive is that it fails quietly. The sender hangs, the feature does nothing, and the only trace is an "Unchecked runtime.lastError" line in a service worker console that nobody has open in production. On the sender side, the callback form sets chrome.runtime.lastError (the promise form rejects), so you can route it somewhere real. If you use the Moderok SDK for extension analytics, Moderok.init() already records uncaught exceptions and unhandled promise rejections automatically, and you can log this specific failure where you check for it:

chrome.runtime.sendMessage({ type: "GET_TOKEN" }, (response) => {
  if (chrome.runtime.lastError) {
    Moderok.captureLastError("runtime.sendMessage", chrome.runtime.lastError, {
      message_type: "GET_TOKEN",
    });
    return;
  }
  useToken(response.token);
});

That records an __error event with the API name and the message text, and identical messages within a short window are deduplicated so a reconnect loop does not flood your logs. Inside the handler, wrap risky work in try/catch and call Moderok.captureError(error, { action: "get_token" }) so a thrown handler shows up in the same error stream instead of vanishing into a closed port. Now a dropped response is a data point you can see and count across your real users, not a bug report you wait months to receive.

If you are instrumenting an extension and want anonymous, MV3-native analytics and error tracking that runs in the service worker with zero dependencies, that is exactly what Moderok is built for.