Waiting for a Tab to Finish Loading

Wait reliably for an MV3 tab to be ready before injecting or messaging: why status complete is not enough, listener-based waiting, timeouts, and handling tabs that never settle.

Published July 25, 2026 Updated July 25, 2026 8 min read
Table of Contents

You create a tab, inject a script, and get “Cannot access contents of the page” or “Receiving end does not exist” — because the tab existed but the document did not yet. Polling chrome.tabs.get until status === "complete" looks like the fix and introduces two new bugs: it misses tabs that were already complete before you started, and it never terminates for tabs that never finish loading. This page is part of Tabs API & Window Management and covers waiting that is both reliable and bounded.

Root cause: readiness is an event you can miss

chrome.tabs.create resolves as soon as the tab object exists, which is long before its document does. status moves from loading to complete and is reported through tabs.onUpdated — an event, not a state you can subscribe to retroactively. So a naive wait has a race at both ends: the transition can happen before your listener is attached, and it may never happen at all for a tab that stalls on a slow subresource.

The window in which a wait can miss the transitionThe tab is created, the listener is attached a moment later, and for a fast local page the complete event can fire in between.tabs.create resolvesdocument readyTab object ex…no document yetYour listener…a microtask la…Loadingnetwork and parsestatus: compl…the event you …a cached page can already be complete herethe event fires once and is not replayed
For a cached or local page the whole load can fit inside the gap — which is why the wait must also check the current state.

Step 1. Attach the listener, then check the current state

The correct order removes the race entirely: subscribe first, then read the tab. If it is already complete, resolve immediately; otherwise the listener will catch the transition.

 1// background.js
 2export function waitForTab(tabId, { timeoutMs = 15_000 } = {}) {
 3  return new Promise((resolve, reject) => {
 4    let settled = false;
 5
 6    const finish = (fn, value) => {
 7      if (settled) return;
 8      settled = true;
 9      chrome.tabs.onUpdated.removeListener(onUpdated);
10      chrome.tabs.onRemoved.removeListener(onRemoved);
11      clearTimeout(timer);
12      fn(value);
13    };
14
15    const onUpdated = (id, changeInfo) => {
16      if (id === tabId && changeInfo.status === "complete") finish(resolve, tabId);
17    };
18    const onRemoved = (id) => {
19      if (id === tabId) finish(reject, new Error("tab-closed"));
20    };
21
22    chrome.tabs.onUpdated.addListener(onUpdated);
23    chrome.tabs.onRemoved.addListener(onRemoved);
24    const timer = setTimeout(() => finish(reject, new Error("tab-load-timeout")), timeoutMs);
25
26    // Subscribe FIRST, then check — otherwise a fast load slips through the gap.
27    chrome.tabs.get(tabId).then(
28      (tab) => { if (tab.status === "complete") finish(resolve, tabId); },
29      (error) => finish(reject, error),
30    );
31  });
32}

Execution context: Extension service worker. Removing both listeners in finish matters more than it looks: an extension that creates many tabs accumulates listeners otherwise, and each one is invoked for every update to every tab. The onRemoved branch turns a closed tab from a timeout into an immediate, correctly-typed failure, which is the difference between a fifteen-second stall and a clean error path.

Step 2. Bound the wait, because some tabs never complete

A page with a hanging request, a long-polling connection or a media stream can stay in loading indefinitely. That is normal browsing, not an error, so the wait needs a timeout and the caller needs a sensible fallback rather than a rejection that aborts the whole job.

 1// background.js
 2export async function withTabReady(tabId, work, { timeoutMs = 15_000 } = {}) {
 3  try {
 4    await waitForTab(tabId, { timeoutMs });
 5  } catch (error) {
 6    if (error.message === "tab-closed") throw error;      // genuinely cannot proceed
 7    // A slow page is still usable: the DOM is normally interactive well before complete.
 8    console.info("proceeding without a complete signal", tabId);
 9  }
10  return work(tabId);
11}

Execution context: Extension service worker. Distinguishing a closed tab from a slow one is what makes the fallback safe — proceeding into a closed tab is pointless, while proceeding into a slow one usually works because the document is interactive long before every subresource has settled. Fifteen seconds is a reasonable default; for a tab the user is watching, shorter is better, because a slow page and a broken one look identical to them.

Step 3. Prefer the readiness signal that matches the work

status === "complete" corresponds to the load event, which waits for images and stylesheets. Most extension work needs the DOM, not the images — and there are cheaper signals for that.

Readiness signals and what each guaranteesThe tabs status field, DOMContentLoaded via webNavigation, an injected content script and a direct message probe compared.SignalFires whenGood forstatus: loadingNavigation committedCancelling stale workwebNavigation.onCommittedDocument createdEarly injectionwebNavigation.onDOMContentLoadedDOM parsedReading the DOMstatus: completeload eventScreenshots, layoutContent script says helloYour code is runningMessaging that tab
Match the signal to the work: waiting for images before reading the DOM is a self-inflicted delay.

The last row is the strongest signal available and the one to use before messaging: the only thing that proves a content script is listening is the content script saying so. Everything above it proves something about the document, not about your code.

Step 4. Probe instead of waiting when you only need to message

If the goal is a message round trip, waiting for load is indirect. Send the message, and on the “no receiving end” rejection inject the script and retry once — which also handles tabs that loaded before your extension was installed.

Probe-then-inject against wait-then-messageThe probe sends immediately and only injects when there is no listener; the wait path pays the load time on every call.Service workerTab with a scriptTab without onesendMessage — succeeds first tryreplysendMessage — no receiving endexecuteScript(files)only on the failure pathsendMessage — retry oncereply
The probe costs one failed send in the uncommon case; the wait costs real seconds in every case.
 1// background.js
 2export async function messageTabEnsuringScript(tabId, message) {
 3  try {
 4    return await chrome.tabs.sendMessage(tabId, message);
 5  } catch (error) {
 6    if (!/Receiving end does not exist|Could not establish connection/i.test(String(error?.message))) {
 7      throw error;
 8    }
 9  }
10
11  // No listener: inject, then try exactly once more. A second failure is a real error.
12  await chrome.scripting.executeScript({ target: { tabId }, files: ["content.js"] });
13  return chrome.tabs.sendMessage(tabId, message);
14}

Execution context: Extension service worker; needs the scripting permission and access to the tab. This inverts the usual order and is almost always better: it costs one failed send in the uncommon case and nothing at all in the common one, whereas waiting for load costs real time on every call. It also fixes the install-time gap that no amount of waiting can — a tab open since before installation has no content script and never will until something injects one, as covered in injecting CSS and JS with executeScript.

Step 5. Cancel work when the tab navigates away

A wait that resolves after the user has navigated elsewhere hands you a tab whose document is not the one you were waiting for. Track the URL you expected and re-check it before acting.

1// background.js
2export async function waitForUrl(tabId, expectedOrigin, options) {
3  await waitForTab(tabId, options);
4  const tab = await chrome.tabs.get(tabId);
5  if (!tab.url || new URL(tab.url).origin !== expectedOrigin) {
6    throw new Error("tab-navigated-away");
7  }
8  return tab;
9}

Execution context: Extension service worker with host access to the tab. Without the origin check, a redirect chain or a user-initiated navigation during the wait silently changes the target — the promise resolves, the injection succeeds, and it runs somewhere you never intended. Reading tab.url requires the tabs permission or matching host access, so treat a missing url as a failed check rather than as a pass.

Cross-browser variation

  • Chrome 120+: status reports loading and complete. webNavigation events give finer-grained signals with the webNavigation permission.
  • Firefox 121+: identical status values, and browser.tabs.onUpdated additionally accepts a filter argument so you can subscribe to one tab rather than all of them.
  • Safari 17+: reports the same statuses, but timing around cached and restored tabs is less consistent — the subscribe-then-check ordering matters more here, not less.
  • All engines: discarded and restored tabs re-run content scripts, so a tab that was ready earlier may not be ready now. Probe rather than remember.

Verification

  1. Create a tab pointing at a local file that loads instantly and confirm the wait resolves, proving the already-complete path works.
  2. Point a tab at a URL that never finishes loading and confirm the wait times out and the fallback proceeds rather than hanging.
  3. Close the tab mid-wait and confirm you get tab-closed immediately rather than waiting out the timeout.
  4. Message a tab that was open before the extension was installed and confirm the inject-and-retry path succeeds.

FAQ

Why does status complete fire more than once?

Because a tab can navigate several times, and each navigation runs the cycle again. Always compare the tab id, and where it matters compare the URL too — a complete for a later navigation is not the one you were waiting for.

Is polling tabs.get ever acceptable?

It is wasteful and racy in both directions, so no. The listener-plus-check pattern costs the same code and has no polling interval to tune.

Should I wait for load before injecting a content script?

Usually not. Injection works from document creation onward, and executeScript with the default injection timing will run once the document exists. Waiting for complete before injecting adds delay that buys nothing.

What if I need the page’s images to have loaded?

That is the one case status === "complete" is genuinely for — screenshots and layout measurement. For anything that reads the DOM, an earlier signal is both faster and equally correct.

Other Core APIs & Cross-Browser Data Management Resources