Ver en Español
Introduction to Service Workers in Chrome Extensions
Jun 24, 2026
Updated: Jun 25, 2026

Introduction to Service Workers in Chrome Extensions

In this post we will explore what service workers are and how they work inside Chrome extensions. We will implement one step by step, understand why they live in a different scope than the window, and learn how to make the background talk to the popup. All the code uses Manifest V3, the current and mandatory version in Chrome (Manifest V2 was fully retired in 2024).

What are Service Workers?

Service workers are background workers that run independently of the DOM (Document Object Model) of the web page or window. They are useful for handling tasks that do not require direct interaction with the user interface, such as making requests to APIs or handling background events.

One important trait in Manifest V3 is that the service worker does not live forever: the browser stops it when it is idle and wakes it up again on an event. Because of this we should not keep state only in in memory variables; it is better to persist it, for example in chrome.storage.

Configuring the Service Worker

First we need to register a service worker in our manifest.json file. Here is a basic example:

{
  "manifest_version": 3,
  "name": "Mi Extensión",
  "version": "0.0.1",
  "background": {
    "service_worker": "background.js"
  }
}

Next, we create the background.js file with the following content:

console.log('Hola desde el script de fondo');

Verifying the Service Worker activity

When you load the extension in Chrome, the service worker will register and run. You can check its activity on the chrome://extensions page: enable developer mode, load your unpacked extension and click the "service worker" link on your extension card to open the associated developer tools and view its logs.

Difference between the Service Worker scope and the window scope

It is crucial to understand that service workers operate in a different scope than the window (or DOM). While the window has access to the user interface and can manipulate DOM elements, service workers do not have that access.

Service Worker scope:

  • It can handle background tasks such as requests to APIs, event handling and more.
  • It has no direct access to the DOM, so it cannot manipulate user interface elements.

Window scope:

  • It includes everything related to the user interface, such as DOM manipulation and interaction with the person using the extension.
  • It can communicate with the service worker by sending messages.

Implementing basic examples

Let's implement a basic example to better understand how these concepts work.

Background script (background.js):

console.log('Hola desde el script de fondo');

Window script (popup.js):

console.log('Hola desde el script de la ventana');

Popup HTML file (popup.html):

<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <title>Pop-up</title>
  <script src="popup.js"></script>
</head>
<body>
  <h1>Hola Mundo</h1>
</body>
</html>

We update manifest.json to declare the popup:

{
  "manifest_version": 3,
  "name": "Mi Extensión",
  "version": "0.0.1",
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html"
  }
}

The background.js file runs in the background and can perform tasks such as handling browser events or coordinating the extension state. The popup, on the other hand, is a user interface that appears when the person clicks the extension icon in the browser toolbar. Communication between these two components is key to many extension features.

Sending messages from the popup to background.js

To send a message from the popup to the background script, we can use the chrome.runtime.sendMessage API. Here is an example from popup.js:

document.addEventListener('DOMContentLoaded', function () {
  document.getElementById('sendMessage').addEventListener('click', function () {
    chrome.runtime.sendMessage({ greeting: 'hola' }, function (response) {
      console.log(response.farewell);
    });
  });
});

And in popup.html:

<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <title>Pop-up</title>
</head>
<body>
  <button id="sendMessage">Enviar Mensaje</button>
  <script src="popup.js"></script>
</body>
</html>

These days, chrome.runtime.sendMessage also returns a Promise when you do not pass a callback, so in modern code you can use await instead of nesting functions:

async function enviarSaludo() {
  const response = await chrome.runtime.sendMessage({ greeting: 'hola' });
  console.log(response.farewell);
}

Note. Both forms are valid. Use the callback when you need compatibility with older code, and the await version when you prefer a cleaner asynchronous flow. Do not mix the two in the same call.

Receiving messages in background.js

In background.js, we listen for and respond to incoming messages with chrome.runtime.onMessage.addListener:

chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
  console.log(
    sender.tab
      ? 'Message from a content script: ' + sender.tab.url
      : 'Message from the extension',
  );

  if (request.greeting === 'hola') {
    sendResponse({ farewell: 'adiós' });
  }
});

Very important note. This is the number one bug in Manifest V3 messaging. If you respond synchronously, as above, everything works. But if you need to do asynchronous work before calling sendResponse (for example, waiting on a fetch), you must return true; from the listener to keep the message channel open. If you forget, the channel closes before your response and the sender will receive undefined.

chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
  if (request.greeting === 'hola') {
    fetch('https://api.example.com/saludo')
      .then((res) => res.json())
      .then((data) => sendResponse({ farewell: data.farewell }));
    return true; // keeps the channel alive for the asynchronous response
  }
});

Sending messages from background.js to the popup

Here it helps to clear up a common misunderstanding. There is no "reference to the active popup" we can get from background.js: the popup is not a window we hold a handle to. What chrome.runtime.sendMessage does is send the message to the extension runtime, and it will only arrive if the popup is open at that moment and has registered a listener with chrome.runtime.onMessage.

In practice this means that if the person does not have the popup open, there will be no one to receive the message. That is why many extensions send data in the other direction: the popup asks the background for the information when it opens.

In background.js:

function sendMessageToPopup() {
  // It will only arrive if the popup is open and listening.
  chrome.runtime.sendMessage({ greeting: 'hola' }, function (response) {
    if (chrome.runtime.lastError) {
      // No receiver: the popup is probably closed.
      console.log('The popup is not open, nobody received the message.');
      return;
    }
    console.log(response.farewell);
  });
}

// You can call sendMessageToPopup whenever you need to.

In popup.js, we listen for messages the same way:

chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
  if (request.greeting === 'hola') {
    sendResponse({ farewell: 'adiós' });
  }
});

Two way communication with ports

For richer, two way communication, we can use ports (port) to keep an open connection between the popup and background.js. Here is a basic example.

In popup.js:

const port = chrome.runtime.connect({ name: 'popup-background' });
port.postMessage({ greeting: 'hola' });
port.onMessage.addListener(function (msg) {
  console.log('Mensaje recibido:', msg);
});

In background.js:

chrome.runtime.onConnect.addListener(function (port) {
  console.assert(port.name === 'popup-background');
  port.onMessage.addListener(function (msg) {
    console.log('Mensaje recibido:', msg);
    if (msg.greeting === 'hola') {
      port.postMessage({ farewell: 'adiós' });
    }
  });
});

Ports are ideal when you are going to exchange several messages during a session, since you avoid opening and closing a channel on every send.

Conclusion

Service workers are a powerful tool for managing background tasks in your Chrome extensions. By understanding the difference between the service worker scope and the window scope, and by handling messaging well, you can build more robust and efficient extensions.

Suggested exercises

  1. Create a minimal Manifest V3 extension that registers a background.js and logs a message to the console on install using chrome.runtime.onInstalled.
  2. Build a popup with a button that sends a message to the background and shows the response. Do it first with a callback, then rewrite it with await.
  3. Change the background listener so it responds asynchronously (for example, after a fetch) and check what happens with and without return true;.
  4. Replace the one off messages with a port connection and send at least three messages in both directions during the same session.

3-point summary

  1. In Manifest V3 the background is a service worker that runs in the background, with no DOM access and no guaranteed lifetime, so it is best to persist state.
  2. Communication between components is done with messages (runtime.sendMessage / onMessage) or with ports (connect / onConnect); remember return true; when the response is asynchronous.
  3. There is no direct handle to the popup: a message from the background only arrives if the popup is open and listening.

That's all. I hope this post is useful to you and that you can apply it to a project you have in mind. Leave me a comment if it helped, if you want to add an opinion, or if you have any questions. And remember, if you liked it, you can also share it using the social links below. Good luck and keep building extensions!

Sebastian Gomez

Sebastian Gomez

Creador de contenido principalmente acerca de tecnología.

Leave a Reply

0 Comments

Advertisements

Related Posts

Categorias