DEV Community

easy1nhard2
easy1nhard2

Posted on

How to Set Up WebSocket Communication Between a Chrome Extension and a Node.js Server

Last summer, I worked on a user-adaptive dark mode system in our lab.

The project eventually got shelved — but the architecture I designed back then taught me a lot about server-driven UI control and real-time communication between backend and browser extensions.

In this post, I’ll share how I designed a system that allows the server to directly modify the client’s UI in real-time using WebSocket, and why the traditional HTTP request-response model was insufficient.


1. The Limitations of HTTP and the Need for WebSocket

In traditional web communication, HTTP follows a strict one-way flow:

Client sends request → Server responds → Connection ends.

This works fine for typical API calls, but not for cases where the server must actively control or update the client’s state — for example, a system that adjusts the browser’s brightness or contrast based on user conditions.

Imagine this:
The server decides that “the user’s screen is too bright” and wants to lower it immediately.
With HTTP, the server can’t act first — it has to wait for the client to make a request.

That’s where WebSocket comes in.

What is WebSocket?

WebSocket is a full-duplex communication protocol that keeps a persistent connection between the client and the server.
After an initial handshake, both sides can freely send and receive data in real time.

Feature HTTP WebSocket
Connection Ends after each request Persistent after handshake
Direction One-way (client → server) Two-way (client ↔ server)
Typical Use APIs, static data Chat, alerts, real-time control
Protocol HTTP/HTTPS ws:// or wss://

With WebSocket, the server can initiate actions — sending commands like “increase font size” or “reduce brightness” to the client instantly.

That’s exactly what my adaptive dark mode system needed.


2. System Overview and Implementation

2.1 Architecture Concept

The system had to be server-driven — meaning, the server sends instructions to modify the client UI dynamically.

To implement this, I used:

  • Node.js (Express + ws) for the backend
  • Chrome Extension (TypeScript) as the client

The Chrome Extension was based on the Dark Reader project, modified to accept WebSocket-based UI control commands from the backend.

Through many trials (and a few CSP headaches), I found that managing the WebSocket connection in background.ts was the most stable and secure approach.

2.2 Why background.ts?

A Chrome Extension typically has three main components:

Component Description Lifecycle
popup The UI window that opens when clicking the extension icon Temporary (closes when UI closes)
content script Injected into web pages to manipulate DOM Reloads with page
background Runs persistently in the background and manages app logic Persistent

Since WebSocket requires a continuous connection, placing it in popup or content script would cause it to disconnect whenever the UI closes or a page refreshes.

Moreover, Chrome Extensions have strict Content Security Policy (CSP) restrictions.
The background script (a service worker) is the only stable place to maintain a persistent WebSocket connection and relay messages between the server, popup, and content scripts.

2.3 Basic System Design

[ Chrome Extension ]
    └─► background.ts
          └─ WebSocket connection
          └─ Send/receive messages with server

[ Node.js Backend Server ]
    └─ WebSocket server (ws://localhost:3001)
          └─ Handle incoming messages and send responses
Enter fullscreen mode Exit fullscreen mode

Backend Setup

npm install
npm install ws
npm install express
Enter fullscreen mode Exit fullscreen mode

Since only the basic structure has been built so far, I’ve written the initial WebSocket handling logic inside the index.js file.

Later, you can start the server simply by running the command:

node index.js
Enter fullscreen mode Exit fullscreen mode

Once the server starts successfully, the waiting WebSocket server will also be activated.

Server Implementation

The following code demonstrates a simple structure where the server receives messages from connected clients and sends the same content back in an echo format.

index.js (server-side)

const WebSocket = require('ws');
const PORT = 3001;
const wss = new WebSocket.Server({ port: PORT });

wss.on('connection', (ws) => {
  console.log('✅ Client connected via WebSocket');

  ws.on('message', (message) => {
    console.log('📨 Client message:', message.toString());
    ws.send(`Server echo: ${message}`);
  });

  ws.on('close', () => {
    console.log('❌ Client disconnected');
  });
});

console.log(`🟢 WebSocket Server running at ws://localhost:${PORT}`);
Enter fullscreen mode Exit fullscreen mode

This simple echo server lays the groundwork for future adaptive commands like “update brightness” or “change contrast.”

Client Implementation (background.ts)

The following code implements the WebSocket client within the Chrome Extension’s background.ts file.

We modified version 3.5.4 of the dark mode extension Dark Reader to include this functionality.
The code below shows the portion that was added to the extension’s background.ts file.

Because attempting to connect directly from the popup is blocked by Content Security Policy (CSP) restrictions, we designed the background service worker, which runs continuously, to handle the persistent connection with the server.

let socket: any = null;

function connectWebSocket() {
  socket = new WebSocket('ws://localhost:3001');

  socket.onopen = () => {
    console.log('[bg] WebSocket connected');
    socket.send('[bg] Extension connected to server');
  };

  socket.onmessage = (event) => {
    console.log('[bg] Server response:', event.data);
    chrome.runtime.sendMessage({ from: 'server', data: event.data });
  };

  socket.onclose = () => {
    console.warn('[bg] Connection closed — retrying...');
    setTimeout(connectWebSocket, 3000);
  };

  socket.onerror = (err) => {
    console.error('[bg] WebSocket error:', err);
    socket.close();
  };
}

// Relay popup messages to the server
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.from === 'popup' && socket?.readyState === 1) {
    socket.send(msg.data);
  }
});

// Initialize
loadConfigs(() => {
  extension = new DarkReader.Extension(new DarkReader.FilterCssGenerator());
  onExtensionLoaded.invoke(extension);
  connectWebSocket();
});
Enter fullscreen mode Exit fullscreen mode

3. Running the System

⚠️ Note: Dark Reader 3.5.4 uses Manifest V2, which is deprecated in modern Chrome versions.
You may need to downgrade Chrome to test it.

3.1 Build the Extension

npm run release
Enter fullscreen mode Exit fullscreen mode

If dependencies are missing, follow npm’s installation suggestions.

3.2 Load in Chrome

  1. Go to chrome://extensions
  2. Enable Developer Mode

  1. Click Load unpacked
  2. Select your unzipped build folder

You should see Dark Reader 3.5.4 appear in the list.

3.3 Run the Backend

node index.js
Enter fullscreen mode Exit fullscreen mode

You should see:

🟢 WebSocket Server running (ws://localhost:3001)
✅ Client connected

In Chrome’s background console(html), you’ll find logs like:

[bg] WebSocket connected
[bg] Server response: [bg] Extension connected to server

Connection success!

4. Adding Dynamic Dark Mode Control

Finally, I added a fun test: every 5 seconds, the server sends random dark mode settings to the extension, which are then applied dynamically.

Updated index.js

const WebSocket = require('ws');
const PORT = 3001;
const wss = new WebSocket.Server({ port: PORT });

wss.on('connection', (ws) => {
  console.log('✅ Client connected');

  ws.on('message', (message) => {
    console.log('📨 Received:', message.toString());
    ws.send(`Server echo: ${message}`);
  });

  const intervalId = setInterval(() => {
    const data = {
      type: 'UPDATE_FILTER',
      payload: {
        mode: 1,
        brightness: Math.floor(Math.random() * 100) + 50,
        contrast: Math.floor(Math.random() * 100) + 50,
        grayscale: Math.floor(Math.random() * 100),
        sepia: Math.floor(Math.random() * 100),
        useFont: Math.random() > 0.5,
        fontFamily: "Open Sans",
        textStroke: Math.floor(Math.random() * 3),
        invertListed: false,
        siteList: []
      }
    };
    console.log('Server sending:', data);
    ws.send(JSON.stringify(data));
  }, 5000);

  ws.on('close', () => {
    console.log('❌ Client disconnected');
    clearInterval(intervalId);
  });
});

console.log(`🟢 WebSocket Server running at ws://localhost:${PORT}`);
Enter fullscreen mode Exit fullscreen mode

Updated background.ts

socket.onmessage = (event) => {
  try {
    const data = JSON.parse(event.data);
    if (data.type === 'UPDATE_FILTER') {
      console.log('[bg] Received UPDATE_FILTER:', data.payload);
      for (const key in data.payload) {
        if (extension.config.hasOwnProperty(key)) {
          extension.config[key] = data.payload[key];
        }
      }
      chrome.tabs.query({}, (tabs) => {
        tabs.forEach(tab => {
          if (tab.id && tab.url) {
            extension["addStyleToTab"](tab);
          }
        });
      });
    }
  } catch (e) {
    console.log('[bg] Server message:', event.data);
  }
};
Enter fullscreen mode Exit fullscreen mode

After running this, you can watch your extension’s theme update dynamically every 5 seconds — brightness, contrast, grayscale, and more.



✅ Conclusion

This project was a valuable experiment in real-time UI adaptation using WebSocket communication between a Node.js backend and a Chrome Extension client.

Key takeaways:

  • HTTP alone isn’t enough for server-driven UIs.
  • WebSocket enables true bi-directional, event-based communication.
  • The background script is the right place to manage persistent connections in Chrome Extensions.

Even though the project never went into production, it laid the groundwork for building adaptive, responsive, and context-aware browser systems — where the server doesn’t just respond, but actively orchestrates the client experience.

Top comments (0)