Skip to main content

Loading Chrome extensions for development in 2025

Google took down the flag, what now? How to load a browser extension in Chrome for development in the post "--load-extension"-era.

· 6 min read
Max Mulatz

For loading a development build of our open source browser extension Tickety-Tick into Google Chrome during local development, we used to use Chrome's handy --load-extension flag. It allowed to load a local unpacked build of the extension via the command line. As this feature was recently removed from all branded builds of Chrome, we needed to update our workflow to use the-new-way™ - "remote debugging pipes".

Background

Tickety-Tick is on the nichier side of tech within our fleet of open source projects: it's a browser extension for establishing conventions on naming branches and writing commit messages in Git. And while we're slightly leaning towards team Firefox among us developers, Tickety-Tick is of course also supporting Google Chrome (and others…). For this, we as developers have to be able to run a local build of the extension in Chrome during development.

The old way

For local development, we had a yarn open:chrome command which would open Chrome with the current local build of the extension already installed, ready to click around. Under the hood, we utilized Google's chrome-launcher package to start a fresh, unpersonalized instance of Chrome for us. The code looked about like this:

const chromeFlags = ChromeLauncher.Launcher.defaultFlags()
.filter((flag) => flag !== "--disable-extensions")
.concat(["--no-default-browser-check", "--load-extension=/dist/chrome"]);

ChromeLauncher.launch({chromeFlags}).then((chrome) => console.log("Chrome running")

The important part is the --load-extension flag which points to the local Chrome build of the extension.

Some day, this just stopped working. Chrome would start as usual, but without the browser extension loaded.

What happened?

Some debugging and internet research brought us to this post by the Chrome team from March 2025:

RFC: Removing the --load-extension flag in branded Chrome builds

Turns out the --load-extension flag was commonly abused by malicious parties and the Chrome team was looking into ways to further restrict this. It lead them to removing this feature from all branded Chrome builds (so Chromium would still have it…) and instead promote other means for loading extensions for testing and development.

What now?

To figure out the new™ way to load an unpacked, locally build extension in Chrome, we just naively searched for the --load-extension flag on GitHub, hoping to find hints how other people updated their code. A promising hit was in the chrome-launcher package itself. If somebody knew how to do it, they should be the ones. The searching pointed us to a really helpful code comment in their tests:

// Note: --load-extension in chromeFlags used to be the primary method of
// loading extensions, but this is removed from official stable Chrome builds
// starting from Chrome 137. This shows the officially supported way to load
// extensions, with --remote-debugging-pipe.
// See: "Removing the `--load-extension` flag in branded Chrome builds"
// https://groups.google.com/a/chromium.org/g/chromium-extensions/c/aEHdhDZ-V0E/m/UWP4-k32AgAJ

As this was in a load-extension-test.ts file, we figured we'd just try to adopt what they are doing in their test setup for our open:chrome script. And as the comment indicates, we need to utilize the --remote-debugging-pipe flag, something we already knew from ChromicPDF our PDF rendering library for Elixir based on headless Chrome.

Following pretty much exactly what the loadExtension via remote-debugging-pipe test was doing, we ended up with an updated open-in-chrome script which would:

  1. Start Chrome with the respective flags

    const chromeFlags = launcher.Launcher.defaultFlags()
    .filter((flag) => flag !== "--disable-extensions")
    .concat([
    "--remote-debugging-pipe",
    "--enable-unsafe-extension-debugging",
    "--no-first-run",
    "--no-default-browser-check",
    ]);

    const options = {
    chromeFlags,
    ignoreDefaultFlags: true,
    startingUrl: url,
    };

    const chrome = await launcher.launch(options);
  2. Get hold of the debugging pipes

    const pipes = chrome.remoteDebuggingPipes;
    if (!pipes) {
    throw new Error("Chrome did not expose remoteDebuggingPipes");
    }
  3. Put together the command to load the extension

    const requestId = Math.floor(Math.random() * 1e6);
    const request = {
    id: requestId,
    method: "Extensions.loadUnpacked",
    params: { path: dir },
    };
  4. Send the request and listen on the pipes

    const firstResponse = new Promise((resolve, reject) => {
    let buffer = "";

    pipes.incoming.on("error", reject);
    pipes.incoming.on("close", () =>
    reject(new Error("Pipe closed before response")),
    );

    pipes.incoming.on("data", (chunk) => {
    buffer += chunk;
    let end;
    while ((end = buffer.indexOf("\x00")) !== -1) {
    const message = buffer.slice(0, end);
    buffer = buffer.slice(end + 1);
    try {
    const parsed = JSON.parse(message);
    if (parsed.id === requestId) {
    resolve(parsed);
    }
    } catch {
    // ignore non-JSON noise
    }
    }
    });
    });

    pipes.outgoing.write(JSON.stringify(request) + "\x00");
  5. Check that the request was successful

    const response = await firstResponse;
    if (response.error) {
    throw new Error(`Failed to load extension: ${response.error.message}`);
    }

With that a fresh Chrome should be running with our extension loaded! 🎉

Our script

In our final script, we also added some emoji-powered checks and debugging statements, so that the CLI output aligns with our other tasks and the webpack build. Here's our final script, maybe you can draw inspiration from it for your own usecase:

#!/usr/bin/env node

// usage: open-in-chrome [extension-dir] [starting-url]

import * as path from "path";
import * as launcher from "chrome-launcher";

const dir = process.argv[2] || path.join(__dirname, "..", "dist", "chrome");
const url = process.argv[3] || "https://github.com/bitcrowd/tickety-tick";

async function launchChrome() {
const chromeFlags = launcher.Launcher.defaultFlags()
.filter((flag) => flag !== "--disable-extensions")
.concat([
"--remote-debugging-pipe",
"--enable-unsafe-extension-debugging",
"--no-first-run",
"--no-default-browser-check",
]);

const options = {
chromeFlags,
ignoreDefaultFlags: true,
startingUrl: url,
};

const chrome = await launcher.launch(options);

if (chrome.port !== 0) {
console.warn(
"⚠️ Expected remote-debugging-pipe mode on port 0, but got a debug port.",
);
}

const pipes = chrome.remoteDebuggingPipes;
if (!pipes) {
throw new Error("Chrome did not expose remoteDebuggingPipes");
}

console.log("🚀 Chrome launched with remote-debugging-pipe.");
console.log(`📂 Loading extension from: ${dir}`);

const requestId = Math.floor(Math.random() * 1e6);
const request = {
id: requestId,
method: "Extensions.loadUnpacked",
params: { path: dir },
};

// --- Send command and wait for response
const firstResponse = new Promise((resolve, reject) => {
let buffer = "";

pipes.incoming.on("error", reject);
pipes.incoming.on("close", () =>
reject(new Error("Pipe closed before response")),
);

pipes.incoming.on("data", (chunk) => {
buffer += chunk;
let end;
while ((end = buffer.indexOf("\x00")) !== -1) {
const message = buffer.slice(0, end);
buffer = buffer.slice(end + 1);
try {
const parsed = JSON.parse(message);
if (parsed.id === requestId) {
resolve(parsed);
}
} catch {
// ignore non-JSON noise
}
}
});
});

pipes.outgoing.write(JSON.stringify(request) + "\x00");

const response = await firstResponse;
if (response.error) {
throw new Error(`Failed to load extension: ${response.error.message}`);
}

console.log(`✅ Extension loaded (id: ${response.result.id})`);
console.log(`🌐 Opening: ${url}`);

chrome.process.on("exit", () => {
console.log("💨 Chrome closed.");
process.exit(0);
});
}

launchChrome().catch((err) => {
console.error("❌ Error:", err);
process.exit(1);
});

What we learned

Comments in code can make other people's lives significantly easier. Think about it the next time you're hesitating to write one!

And: searching GitHub for something as specific as a Chrome flag, can yield helpful results in any kind of file, even in tests.

Max Mulatz

Max Mulatz

Whitespace wrestler on a functional fieldtrip

We’re hiring

Work with our great team, apply for one of the open positions at bitcrowd