Skip to main content

6 posts tagged with "Chrome Extension"

View all tags

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.