Skip to main content

8 posts tagged with "Chrome Extension"

View all tags

Cannot access before initialization in ESM? Implicit globals

Β· 7 min read

Clicking the "Extract this page" button in the browser extension throws Cannot access 'Hc' before initialization β€” while typecheck, build, and the regular test suite all pass, and the error never reproduces in local development.

I hit this while building an e-commerce automated data collection tool for a client β€” bulk-scraping product images, SKUs, prices, and reviews, cleaned and exported as structured data for inventory management and competitor analysis. "Extract this page" is the collection entry of that toolchain, and the content script module was what broke.

TL;DR​

A third-party classic script (vendored) contains an implicit global assignment (CandidateElement = function(...), assigned without declaration). Legal in a classic script β€” it silently creates a global β€” the line becomes a ReferenceError once the file is inlined into an ESM module graph that runs in strict mode. Module initialization aborts on the spot, and the namespace the downstream code receives through dynamic import sits in the temporal dead zone (TDZ). Fix: add one line var CandidateElement; at the top of the vendor file.

The symptom: green build, instant crash on click​

The error fires when the user clicks, not at page load:

TypeError: Cannot access 'Hc' before initialization

Three counterintuitive facts:

  1. typecheck passes β€” nothing wrong at the type level;
  2. build passes β€” the bundler does static analysis only, it never executes module code;
  3. regular tests pass β€” no test imports the module graph completely.

The failing identifier is Hc, a 2-letter minified name, not any business symbol. Hold that thought; it matters for identification later.

Root cause: how an implicit global breaks the ESM module graph​

The offending line comes from a third-party library, at reader-finder.js:878:

// classic script semantics: assign without declaring = create a global, legal
CandidateElement = function(e, t) { ... }

The failure chain has 4 steps, each enabling the next:

Step 1: legal under classic script semantics. The file originally loaded via a <script> tag; in sloppy mode, assigning without declaring silently creates a global variable β€” the original author relied on exactly that.

Step 2: a minefield once inlined into ESM. The file got inlined into the extension's ESM module graph, and ESM code always runs in strict mode β€” the implicit global assignment now throws a ReferenceError, and module evaluation of that vendor module aborts immediately.

Step 3: the break spreads along the module graph. The content script content.js evaluates its inline module graph and stalls at the vendor module: earlier modules got their message listeners registered, but later module facades never executed β€” the graph is left half-initialized.

Step 4: the dynamic import lands in the TDZ. When the user clicks the button, code loads the namespace facade through dynamic import. Because of the step-3 break, that namespace is in the temporal dead zone, and touching it throws Cannot access 'Hc' before initialization β€” Hc being the renamed internal binding of the module that never finished initializing.

This explains every observation: static checks stay green because they never execute module code; the error appears on click because the dynamic import lives inside the click handler; and the identifier is minified because the broken binding was renamed by the bundler.

For the other high-frequency dynamic import pitfall (module not found), see: Node.js ESM dynamic import says module not found? Check the file extension.

The fix: one var declaration at the vendor file head​

No third-party logic changes β€” just turn the implicit global into an explicit declaration by adding at the top of the vendor file:

var ReaderArticleFinder;
var CandidateElement;

The assignment changes from "create a global" to "assign to a declared variable", which is legal in strict mode. Module initialization completes, and the facade downstream code imports dynamically works as expected.

ReaderArticleFinder in the same file was already handled this way β€” the same library planted the same trap twice; the first got fixed, the second (CandidateElement) slipped through.

Verification: reproduce it locally with Vitest​

Before fixing, make it reproduce on demand β€” otherwise every check means a production deploy. Regular tests never reach this path, but importing the module directly in Vitest (jsdom environment) does:

import { describe, it, expect } from 'vitest';

describe('vendor reader-finder strict-mode', () => {
it('initializes the full module graph without a ReferenceError', async () => {
const mod = await import('./lib/vendor/reader-finder');
expect(mod).toBeDefined();
});
});

This test reproduced the error before the fix and produced a stack with real file line numbers (reader-finder.js:878) β€” far more actionable than a minified Hc from production. It turned green after the fix.

The complete verification runs the extraction chain end to end: the module graph initializes fully and extraction actually works β€” both must pass to call it closed.

Regression guard​

The reproduction case became a permanent smoke test (extractor.test.ts), plus a rule for the repo: any new classic script vendored into the project must pass this test, or an equivalent strict-mode check, before landing.

Watch out

  • Keep vendor files close to the original for upstream diffs; when adding a var declaration, leave a comment at the file head explaining why, so the next vendor update does not wash it away as a conflict.
  • Implicit globals rarely come alone: search the whole file for assign-without-declare patterns before declaring β€” the same file had 2 in this case.
  • Bundler choice is irrelevant β€” esbuild, rollup, same outcome β€” because strict-mode semantics are a language-level fact.

Quick identification for this kind of TDZ error​

Next time Cannot access 'xxx' before initialization shows up, two traits tell you whether it is the same species:

TraitThis problemOther TDZ problems
Failing identifierminified short name (Hc, Wt)business symbol (myConfig)
Timingon interaction (dynamic import)at module/page load
Static checksall greenoften caught (let/const redeclaration class)

Left column on both rows: suspect an implicit global in a vendored classic script β€” search for assign-without-declare and reproduce with a direct Vitest import. The other classic ESM migration error (CJS require ESM) is covered here: Node.js require nanoid throws ERR_REQUIRE_ESM? Alternatives after v5 went ESM-only.

FAQ​

Why does 'Cannot access before initialization' only appear at runtime when typecheck and build are green?​

Static checks and bundlers never execute module code, while the ReferenceError from an implicit global assignment fires only when the module actually initializes. If the offending module sits on a dynamic import chain, the error defers to the moment of interaction β€” in this case, clicking the extension button, with 0 errors at build time. Importing the module directly in Vitest (jsdom) reproduces it locally with real line numbers.

What does 'Cannot access xxx before initialization' have to do with the temporal dead zone (TDZ)?​

The namespace object returned by a dynamic import stays in the temporal dead zone once its dependency module's initialization broke, so touching any export throws. The giveaway is the identifier name: a 2-letter minified name like Hc instead of a business symbol means the break happened during module graph evaluation, not in your code.

How do I fix implicit globals in a vendored classic script?​

Declare each implicit global at the top of the vendor file with var (2 in this case: ReaderArticleFinder and CandidateElement) so the assignment targets a declared binding. One line removes the whole module-graph initialization break; run a strict-mode smoke test before any new vendor file lands.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Old Browser Extension Stops Syncing Mid-Month? Version Skew from a Re-purposed Sync Flag

Β· 5 min read

After the "crowd asset" collection feature of our browser extension shipped, support started hearing the same story: some users open crowd assets mid-month and see "already up to date" β€” while their data stops at the 1st. The un-upgraded old extension had stopped collecting, and would only resume on the first day of the next month.

Encountered this while building e-commerce data collection tooling β€” a one-stop e-commerce operations solution from data collection to smart analytics; the collection flag is served by the backend, and old and new extension versions share the same server-side table.

TL;DR​

The new release gave the existing boolean flag is_current_month a new meaning ("this month's collection window is covered"); un-upgraded clients read it with the old meaning ("data is current"), their idempotency check short-circuited, and collection stopped mid-month. This is not a bug to fix β€” it's version skew: one field, two interpretations. The impact has a natural self-healing boundary (the flag flips false next month); the response is upgrade prompts + idempotent upsert (no rollback), and the prevention is: semantic changes must come with a new field.

Symptoms​

New extension collects: writes the current-month row, is_current_month = true

Old extension syncs: reads that row β†’ hits the "up-to-date" check β†’ stops collecting
User's view: mid-month, "crowd assets" says already current; data frozen at month start
Next month, day 1: is_current_month flips false β†’ old version resumes (self-heals)

The eerie part: server data is perfectly correct, the new extension works, the old extension's code never changed β€” the only failure mode is "old version reading rows written by the new version".

Root Cause​

Textbook version skew. The flag's name stayed, its meaning moved: to the new release it means "current-month window covered"; the old release, following its own historical semantics, reads the same row as "data is current" β€” and its perfectly-correct idempotency check ("already current β†’ skip re-collection") short-circuits the whole collection. Both sides' logic is right; the wrong part is letting two semantics share one field.

Client (and every client-side) version distribution is outside the server's control: after a release, old and new versions coexist for weeks as the norm. Any change to a server-side field's meaning is read by every historical version β€” the same lesson as the classic feature-flag mistake: re-purpose an old flag to carry a new meaning, and readers act on the old meaning.

Solution​

Immediate response: prompt upgrades + idempotent writes​

When releasing the version with the new collection logic, explicitly ask users to upgrade β€” the old version will not recover on its own within the month. Server and data need no rollback: collection writes are idempotent upserts, so interleaved old/new writes produce no dirty data, and everything realigns when the flag flips next month.

Long-term prevention: new semantics, new field​

Move the "window semantics" off the boolean onto a new field with an explicit window; old clients that don't know the field can't misread it:

// anti-pattern: old flag re-purposed for new semantics
{ "is_current_month": true }

// correct: new semantics in a new field, explicit and comparable
{ "collected_window": "2026-08", "source_version": "2.3.0" }

The idempotency key decouples from business semantics: old versions judge by the old field, new versions by the new one, no cross-contamination.

Design principle: give every flag a self-healing boundary​

Prefer binding flags to natural time boundaries (month, day) rather than absolute semantics like "current". The month boundary here capped the worst case at one month; without such a boundary, skew is permanent and only a release can fix it.

Notes

  • You don't control the client version distribution. Treat any semantic change to a server-side field as a compatibility change: enumerate all readers and verify each interpretation path.
  • A self-healing boundary is a safety net, not a plan β€” "it'll be fine next month" is not an acceptable long-term state; make the business call explicitly.
  • Idempotent writes (upsert) are the precondition for a safe overlap period; collection pipelines without idempotency will produce duplicates or conflicts the moment versions coexist.
  • When debugging collection pipelines, isolate production data first β€” see adding a DRY-RUN mode to your Chrome extension collector.

FAQ​

What are the best practices for feature flags?​

One flag, one meaning. New semantics get a new field, not a re-purposed old flag. Bind flags to explicit time windows or versions. Before changing any meaning, enumerate every reader β€” especially un-upgraded clients β€” and confirm they won't interpret new values with old semantics. The mid-month collection stop here is the direct consequence of re-using an old flag.

How do I keep server-side fields backward compatible?​

Add, never mutate: existing fields keep their meaning, type, and behavior; new semantics go into new fields; old clients ignore what they don't recognize. Keeping a field's name while changing its meaning is a breaking change to every existing reader.

What should old clients do when they read data with new semantics?​

Three steps: bound the impact to a self-healing window (a calendar-month flip restores collection automatically); prompt upgrades at release to shorten the overlap window; keep server writes idempotent (upsert) so interleaved old/new writes blend safely β€” no rollback, no data cleanup.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Chrome Extension chrome.alarms Fires at the Wrong Interval? MV3 Enforces a ~1 Minute Minimum

Β· 5 min read

An MV3 extension uses chrome.alarms with a 10-second period to flush logs, but in production it turns out to fire only once a minute β€” the schedule is silently wrong.

Encountered this while building the ecommerce data collection tool for a client β€” the extension's background service worker needs to periodically batch-upload accumulated client logs to the server. A 10-second cadence was meant to keep things near-real-time, but in production the worst case was a full minute of latency.

TL;DR​

The MV3 service worker sleeps, so scheduled tasks must use chrome.alarms (setInterval is unreliable); and Chrome enforces a minimum period of about 1 minute on chrome.alarms in production, silently clamping periodInMinutes < 1 up to 1. The fix is to treat 1 minute as your floor and add a "flush immediately when the buffer fills" trigger to cover high-throughput periods.

The Problem​

The log relay is written to flush every 10 seconds:

// background.js (MV3 service worker)
chrome.alarms.create('log-flush', { periodInMinutes: 0.16 }); // aiming for ~10s

chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'log-flush') {
flushLogs();
}
});

It seems fine locally (unpacked), but after packaging and publishing to the store, the listener fires only once a minute β€” periodInMinutes: 0.16 is ignored by Chrome. No error, just a stretched schedule.

Root Cause​

Two constraints stack.

First: setInterval doesn't work under MV3. The Manifest V3 background is a service worker, which Chrome suspends after roughly 30 seconds of idleness to save power. When it suspends, setInterval stops, and on wake-up it doesn't run the missed ticks. So any task that must run "even when the page or extension is idle" has to use chrome.alarms β€” Chrome's native scheduler that can wake the service worker.

Second: chrome.alarms has a minimum period. For performance and battery, Chrome has long enforced a ~1-minute minimum on alarms: periodInMinutes < 1 is clamped to 1. Dev mode (unpacked / Dev channel) is more permissive and runs shorter periods, so local tests pass; but once packaged into a release build, Chrome snaps it back to 1 minute. That's the root of "works locally, stretches in production."

Together: you must use chrome.alarms, and you can't rely on it firing faster than 1 minute.

Solution​

Since 1 minute is a hard floor, treat it as the worst-case backstop and add an event-driven immediate trigger for real-time needs β€” belt and suspenders:

// 1. Backstop timer: once a minute, guarantees a flush even if the worker was suspended
const FLUSH_THRESHOLD = 50;
chrome.alarms.create('log-flush', { periodInMinutes: 1 }); // stop fighting < 1

chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'log-flush') {
flushLogs().catch(() => {});
}
});

// 2. Immediate trigger: check on every log entering the buffer; flush when the threshold is reached
messageBus.on('log', (entry) => {
pushBuffer([entry]);
if (memBuffer.length >= FLUSH_THRESHOLD) {
flushLogs().catch(() => {}); // high-throughput periods flush within seconds
}
});

This combination absorbs both constraints:

  • The 1-minute floor answers "does the timer still run after the worker suspends" β€” chrome.alarms wakes the worker on schedule, so worst-case latency is capped at 1 minute and logs never pile up indefinitely while the extension is idle;
  • The buffer-full trigger answers "do we have to wait a full minute during bursts" β€” once the threshold accumulates within a short window, it flushes right away, bypassing the alarm. Low throughput leans on the alarm, high throughput leans on events, neither end stalls.

The migration cost is tiny: wherever you expected "every 10 seconds," switch to "buffer hits 50 entries OR 1 minute, whichever comes first." Batch-friendly workloads like logs are essentially free; for latency-sensitive single-item tasks, you should redesign them to be event-driven rather than polled.

Caveats

  • Don't use setInterval for critical MV3 service-worker scheduling β€” it stops when the worker suspends and doesn't catch up on wake, the sneakiest source of "intermittent missed tasks" in production. chrome.alarms is the only reliable persistent scheduler under MV3.
  • Treat periodInMinutes as 1 minute in production. Dev mode's shorter periods will fool you β€” always re-test the cadence with the packaged build in a real environment, don't trust dev mode alone.
  • If your feature genuinely needs "exactly every N seconds" precision (a precise countdown), alarms can't deliver β€” they're coarse-grained "no sooner than 1 minute" scheduling that Chrome may delay further. In that case, run the timer with setInterval inside an active page, and let the worker only backstop it.
  • Another frequent service-worker trap is losing the logged-in state β€” see Chrome Extension Service Worker can't read the login state? A cross-context token sync solution.

FAQ​

Why doesn't my chrome.alarms period take effect and gets stretched to 1 minute?​

For performance and battery, Chrome enforces a roughly 1-minute minimum on alarms, so a periodInMinutes below 1 is clamped to 1. Dev mode (unpacked) usually allows shorter periods, but the production build published to the store is snapped back to 1 minute β€” which is why it works locally but stretches online.

Can I use setInterval for scheduled tasks in an MV3 service worker?​

Not reliably. An MV3 service worker is suspended by Chrome after about 30 seconds of idleness, and setInterval stops with it, without running the missed ticks on wake. For persistent scheduling you must use chrome.alarms (which can wake the worker), or persist state to chrome.storage and catch up based on elapsed time when the worker wakes.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Chrome Extension Messages Leaking? postMessage targetOrigin Wildcard Exposes Data to Third-Party Iframes

Β· 3 min read

Encountered this issue while developing a Chrome extension data collection tool for a client. Here's the root cause and solution.

TL;DR​

window.postMessage(data, '*') broadcasts the message to all frames in the page, including third-party iframes. If your Chrome extension passes user data (like memberId, business reports) via postMessage, any embedded iframe can intercept it. Change '*' to window.location.origin to restrict the recipient precisely.

The Problem​

A Chrome extension's injected script passes e-commerce data to its content script via postMessage:

// ❌ Unsafe: message broadcasts to ALL frames
window.postMessage({
type: 'CCL_SHOP_REPORT_DAILY',
memberId: 'b2b-2214126315258ad300', // user ID
rows: [{ uv: 403, payAmt: 19478.47 }] // business data
});
// Equivalent to window.postMessage(data, '*')

If the page embeds third-party iframes (ads, analytics, social widgets), their message event listeners will also receive this message.

Root Cause​

The second argument to postMessage, targetOrigin, determines the message's delivery scope:

targetOriginBehavior
'*' or omittedBroadcasts to all frames, no origin check
'https://example.com'Only delivers to frames with origin https://example.com
window.location.originOnly delivers to frames same-origin as the current page

When omitted, the browser defaults to '*'. This is especially dangerous in Chrome extension scenarios β€” injected scripts run on e-commerce platform pages that may contain multiple third-party iframes.

Solution​

Wrap postMessage in a Safe Helper​

// safePostMessage: enforce window.location.origin
function safePostMessage(data) {
window.postMessage(data, window.location.origin);
}

// Usage
safePostMessage({
type: 'CCL_SHOP_REPORT_DAILY',
subType: 'daily',
memberId: memberId,
rows: [row]
});

Validate Origin on the Receiving End Too​

// Content script message listener
window.addEventListener('message', (event) => {
// βœ… Verify origin
if (event.origin !== window.location.origin) return;

// βœ… Validate message structure
if (!event.data || typeof event.data.type !== 'string') return;

switch (event.data.type) {
case 'CCL_SHOP_REPORT_DAILY':
handleDailyReport(event.data);
break;
case 'CCL_ITEM_WEEKLY_REPORT':
handleWeeklyReport(event.data);
break;
}
});

When is '*' Acceptable?​

Only when the message contains zero sensitive information and the recipient's origin is unpredictable β€” e.g., a pure UI state notification like "panel opened". Even then, window.location.origin is safer.

Caveats​

Caveats

  • Chrome extension MAIN world scripts and content scripts run in separate JavaScript isolation contexts β€” postMessage is their standard communication channel. Protect it carefully (if you encounter duplicate message processing after hot reload, you'll also need to manually clean up old listeners).
  • Receiver-side event.origin validation and sender-side targetOrigin restriction are both required. One-sided protection is incomplete.
  • If messages need to cross origins (e.g., from page to extension background), use chrome.runtime.sendMessage (see Service Worker Token Sync) instead of postMessage.

Chrome Extension Collecting Empty Data? Same-Site Cookies with Different Formats Break ID Mapping

Β· 3 min read

Encountered this issue while building an e-commerce data collection system for a client. Here's the root cause and solution.

TL;DR​

The 1688 platform sets two cookies (last_mid and unb) both containing user IDs, but in different formats (b2b-xxx vs plain digits). The database stores the b2b- prefix format. The original code iterated the cookie array in the outer loop, matching unb first β€” the strict equality check failed, and all collected data was written with zero values because it couldn't map to a shop.

Fix: Put the cookie key priority list in the outer loop and the cookie array in the inner loop, ensuring high-priority keys are checked first.

Problem​

After collecting daily shop reports from 1688, all dashboard metrics in the database were 0, while inquiry data had values:

shop_id | report_date | reveal_cnt | uv | pay_amt | effective_inq_users
2 | 2026-05-13 | 0 | 0 | 0.00 | 56
2 | 2026-05-14 | 0 | 0 | 0.00 | 41

Server log: No shop found for memberId: 2214126315258

But the database stored platform_account_id as b2b-2214126315258ad300.

Root Cause​

Two cookies with different formats on the same domain​

unb=2214126315258                          # plain digits
last_mid=b2b-2214126315258ad300 # b2b- prefix + suffix

The shops.platform_account_id column stores the b2b- prefix format. The code used strict equality:

const match = mapping.find(m => m.platform_account_id === memberId);
// "2214126315258" !== "b2b-2214126315258ad300" β†’ no match

Iteration order trap​

The original code had the cookie array in the outer loop and the key list in the inner loop:

// ❌ Wrong: cookie array outer, key priority ineffective
var keys = ['last_mid', '__last_memberid__', 'unb'];
for (var i = 0; i < cookies.length; i++) { // outer: cookies
var pair = cookies[i].trim();
for (var k = 0; k < keys.length; k++) { // inner: keys
if (pair.indexOf(keys[k] + '=') === 0) {
return pair.substring(keys[k].length + 1);
}
}
}

document.cookie order is not guaranteed. If unb appears before last_mid in the array, it gets matched first β€” returning the plain-digit format and making last_mid priority useless.

Solution​

Swap loop levels: key priority list in the outer loop, cookie array in the inner loop:

// βœ… Correct: key priority list in outer loop
var keys = ['last_mid', '__last_memberid__', 'unb'];
for (var k = 0; k < keys.length; k++) { // outer: iterate keys by priority
for (var i = 0; i < cookies.length; i++) { // inner: search all cookies
var pair = cookies[i].trim();
if (pair.indexOf(keys[k] + '=') === 0) {
return pair.substring(keys[k].length + 1);
}
}
}

The key list is iterated by priority in the outer loop, so last_mid is always checked first regardless of document.cookie order, guaranteeing the user ID format matches the database.

Verification​

// Confirm both cookies exist in browser console
document.cookie.split(';')
.filter(c => /last_mid|unb/.test(c.trim()))
.map(c => c.trim())
// ['unb=2214126315258', 'last_mid=b2b-2214126315258ad300']

Note

This issue doesn't affect chrome.cookies.get() in extension pages β€” it queries by name directly. But when parsing document.cookie strings, always pay attention to loop levels. Also, if your extension passes data via postMessage, watch out for the targetOrigin wildcard security risk; and if messages are processed twice after hot reload, you'll need to manually manage listener lifecycle.


Chrome Extension Processing Messages Twice After Hot Reload? WXT HMR Stacks Listeners

Β· 2 min read

Encountered this issue while building an e-commerce data collection Chrome extension for a client. Here's the root cause and solution.

TL;DR​

WXT framework's HMR re-executes content scripts on file changes but doesn't clean up old window.addEventListener('message', ...) handlers. Each hot reload adds another listener β€” every postMessage fires all instances.

Fix: Before registering a new listener, retrieve the old one from a window variable and call removeEventListener.

Problem​

Browser console showed each message caught by two different instances:

content.js:114 [CCL] CCL_SHOP_REPORT_DAILY daily caught - instance: nxctn6
content.js:2 [CCL] CCL_SHOP_REPORT_DAILY daily caught - instance: t6jce7

Every postMessage processed twice, causing duplicate requests to the backend.

Root Cause​

WXT (a Vite-based Chrome extension framework) in dev mode triggers HMR on content script changes:

  1. The new content script module loads and executes
  2. A new window.addEventListener('message', messageListener) is registered
  3. The old listener function remains in memory β€” HMR doesn't clean up DOM event listeners

Result: multiple independent message listeners on window, each postMessage triggering all of them.

Solution​

At the content script entry point, remove the old listener before registering the new one:

const instanceId = Math.random().toString(36).slice(2, 8);

// Retrieve old listener reference
const prevListener = (window as any).__cclMessageListener;
if (prevListener) {
window.removeEventListener('message', prevListener);
}

// Define new listener
const messageListener = (event: MessageEvent) => {
// ... handling logic
};

// Store current reference (for next HMR cycle)
(window as any).__cclMessageListener = messageListener;

// Register
window.addEventListener('message', messageListener);

Key insight: removeEventListener requires the exact same function reference as addEventListener. By storing the function on window, the next HMR cycle can retrieve and remove the old one correctly. This ensures only one active message listener exists at any time, regardless of how many hot reloads occur.

If you're also seeing postMessage data leaking to third-party iframes or empty collected data due to cookie format mismatch, check those issues too.

Note

  • This issue isn't limited to WXT β€” any framework that hot-reloads content scripts (Plasmo, CRXJS, etc.) can encounter it
  • window variables persist until page refresh; HMR only replaces script modules, not window properties
  • Production builds don't have this issue (content script loads once), but it causes hard-to-debug duplicate requests during development

Chrome Extension Writing Test Data to Production? Add a DRY-RUN Switch

Β· 3 min read

TL;DR​

Chrome extensions submitting collected data via API write directly to the production database β€” even during development and testing. A 3-layer DRY-RUN switch solves this: set an env variable in .env.development β†’ client reads it and adds an X-Dry-Run header β†’ server intercepts the header and returns a data preview without writing. Production never sets the variable, so it's completely unaffected.

Chrome Extension Service Worker Can't Read Login Token? Cross-Context Token Sync

Β· 3 min read

TL;DR​

Chrome extension uses a sidepanel as the UI. User logs in, token goes to localStorage. But the Service Worker (background script) has no localStorage β€” calling it throws ReferenceError. Fix: after login, send the token to the Service Worker via chrome.runtime.sendMessage, which writes it to chrome.storage.local. Sidepanel reads localStorage, Service Worker reads chrome.storage.local.