Chrome Extension Content Script Performance — Best Practices
6 min readContent Script Performance Optimization
Overview
Content scripts directly impact page load performance and user experience. Optimizing their initialization and runtime behavior is critical for maintaining fast page loads and responsive extension functionality.
Minimizing Initial Load Impact
Content scripts block page rendering if loaded at document_start. Use document_idle for non-critical functionality:
{
"content_scripts": [{
"matches": ["https://*.example.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}]
}
For critical features needing early injection, defer non-essential initialization using requestIdleCallback.
Deferred Initialization Pattern
Defer expensive operations until the page has settled:
// Lazy content script initializer
function initContentScript() {
// Schedule non-critical work for idle time
if ('requestIdleCallback' in window) {
requestIdleCallback(() => initializeFeatures(), { timeout: 2000 });
} else {
setTimeout(() => initializeFeatures(), 100);
}
}
function initializeFeatures() {
// Heavy initialization: DOM scanning, event binding, etc.
scanDOM();
attachListeners();
}
initContentScript();
Efficient DOM Querying
Cache DOM queries and avoid repeated searches:
// BAD: Query multiple times
document.querySelectorAll('.item').forEach(el => el.classList.add('processed'));
// GOOD: Single query, cached result
const items = document.querySelectorAll('.item');
items.forEach(el => el.classList.add('processed'));
Use querySelector for single elements and scope queries to specific containers:
const container = document.getElementById('extension-root');
const button = container.querySelector('.action-btn');
Intersection Observer for Lazy DOM Operations
Defer DOM manipulation until elements are visible:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
hydrateComponent(entry.target);
observer.unobserve(entry.target);
}
});
}, { rootMargin: '100px' });
document.querySelectorAll('.lazy-load').forEach(el => observer.observe(el));
MutationObserver Performance
Use targeted observation instead of monitoring entire subtrees:
// BAD: Watches entire document
const observer = new MutationObserver(callback);
observer.observe(document, { subtree: true, childList: true });
// GOOD: Target specific container
const observer = new MutationObserver(callback);
observer.observe(document.querySelector('#comments'), {
childList: true,
subtree: false
});
Disconnect observers when no longer needed to prevent memory leaks:
// When extension feature is disabled or page navigates
observer.disconnect();
Avoiding Layout Thrashing
Read layout properties together, then write together:
// BAD: Interleaved reads and writes cause reflows
for (const el of elements) {
const height = el.offsetHeight; // READ - causes reflow
el.style.height = height + 'px'; // WRITE - causes reflow
}
// GOOD: Batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight); // All READS
elements.forEach((el, i) => el.style.height = heights[i] + 'px'); // All WRITES
CSS Containment for Injected UI
Use CSS containment to isolate extension UI from page reflows:
.extension-container {
contain: content;
isolation: isolate;
}
Message Batching
Reduce IPC overhead by batching messages:
// Batched message sender
const messageQueue = [];
let batchTimeout = null;
function sendBatchedMessage(type, payload) {
messageQueue.push({ type, payload, timestamp: Date.now() });
if (!batchTimeout) {
batchTimeout = setTimeout(flushMessages, 100);
}
}
function flushMessages() {
if (messageQueue.length === 0) return;
chrome.runtime.sendMessage({
type: 'BATCH',
payload: messageQueue.splice(0)
});
batchTimeout = null;
}
Memory Leak Prevention
Always clean up observers, listeners, and timers:
class ContentFeature {
constructor() {
this.observer = null;
this.listeners = [];
this.init();
}
init() {
this.observer = new MutationObserver(this.handleMutations.bind(this));
this.observer.observe(document.body, { childList: true });
window.addEventListener('resize', this.handleResize);
this.listeners.push({ target: window, type: 'resize', handler: this.handleResize });
}
destroy() {
// Disconnect observer
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// Remove all event listeners
this.listeners.forEach(({ target, type, handler }) => {
target.removeEventListener(type, handler);
});
this.listeners = [];
}
}
Script Injection Timing Tradeoffs
| Timing | Use Case | Tradeoff |
|---|---|---|
document_start |
Page modification, style injection | May block rendering |
document_end |
DOM ready, no layout yet | Fast but limited access |
document_idle |
Most features | Best balance, but delayed |
Related Patterns
- DOM Observer Patterns - Advanced observer configurations
- Content Script Lifecycle - Initialization and cleanup
- Performance Profiling - Measuring and debugging performance -e —
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.