Common Userscript Pitfalls
Mistakes that break userscripts and how to avoid them.
Pitfall 1: @match Too Broad
Running on every page slows the browser and causes unexpected behaviour.
Wrong:
// @match *://*/*
// @match https://*/*
Right:
// @match https://example.com/*
// @match https://*.example.com/*
Why it matters: Overly broad patterns mean your script runs on thousands of sites, consuming memory and potentially breaking pages.
Pitfall 2: Missing @connect
Cross-origin requests fail silently or show permission dialogs without @connect.
Wrong:
// @grant GM_xmlhttpRequest
// No @connect declaration
GM_xmlhttpRequest({
url: 'https://api.example.com/data', // Will prompt or fail
...
});
Right:
// @grant GM_xmlhttpRequest
// @connect api.example.com
GM_xmlhttpRequest({
url: 'https://api.example.com/data', // Works
...
});
Best practice: Declare all known domains, then add @connect * as fallback.
Pitfall 3: Not Waiting for Elements
Elements may not exist when your script runs, especially on SPAs.
Wrong:
// @run-at document-end
// Element might not exist yet!
document.querySelector('#dynamic-content').textContent = 'Modified';
// TypeError: Cannot read property 'textContent' of null
Right:
// Use waitForElement pattern
async function init() {
const element = await waitForElement('#dynamic-content');
element.textContent = 'Modified';
}
function waitForElement(selector, timeout = 10000) {
return new Promise((resolve, reject) => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const observer = new MutationObserver((_, obs) => {
const el = document.querySelector(selector);
if (el) {
obs.disconnect();
resolve(el);
}
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => {
observer.disconnect();
reject(new Error(`Timeout: ${selector}`));
}, timeout);
});
}
init();
Pitfall 4: Blocking Async Operations
GM_xmlhttpRequest is asynchronous - you can't use its return value directly.
Wrong:
const response = GM_xmlhttpRequest({
method: 'GET',
url: 'https://api.example.com/data'
});
console.log(response.responseText); // undefined!
Right (callback):
GM_xmlhttpRequest({
method: 'GET',
url: 'https://api.example.com/data',
onload: function(response) {
console.log(response.responseText); // Works
}
});
Right (async/await with GM.*):
const response = await GM.xmlHttpRequest({
method: 'GET',
url: 'https://api.example.com/data'
});
console.log(response.responseText); // Works
Pitfall 5: CSP Blocking Script Injection
Content Security Policy blocks dynamically created scripts.
Wrong:
const script = document.createElement('script');
script.textContent = 'console.log("blocked by CSP")';
document.head.appendChild(script); // Blocked!
Right:
// @grant GM_addElement
GM_addElement('script', {
textContent: 'console.log("bypasses CSP")'
});
Pitfall 6: Sandbox Context Confusion
Without unsafeWindow, you can't access page variables.
Wrong:
// @grant none
// Trying to access page's React app
console.log(window.React); // undefined in sandbox
Right:
// @grant unsafeWindow
console.log(unsafeWindow.React); // Works
Note: @grant none disables the sandbox entirely (different approach).
Pitfall 7: Memory Leaks in Observers
MutationObservers that never disconnect consume memory.
Wrong:
const observer = new MutationObserver(() => {
processNewContent();
});
observer.observe(document.body, { childList: true, subtree: true });
// Never disconnected - runs forever!
Right:
const observer = new MutationObserver(() => {
if (shouldStop()) {
observer.disconnect();
return;
}
processNewContent();
});
observer.observe(document.body, { childList: true, subtree: true });
// Or disconnect on page unload
window.addEventListener('beforeunload', () => observer.disconnect());
Pitfall 8: Overly Aggressive DOM Modifications
Modifying the DOM too frequently causes performance issues.
Wrong:
// Runs on EVERY mutation
const observer = new MutationObserver(() => {
document.querySelectorAll('.item').forEach(el => {
el.style.color = 'red'; // Runs thousands of times
});
});
Right:
// Debounce modifications
let timeout;
const observer = new MutationObserver(() => {
clearTimeout(timeout);
timeout = setTimeout(() => {
document.querySelectorAll('.item:not(.processed)').forEach(el => {
el.style.color = 'red';
el.classList.add('processed');
});
}, 100);
});
Pitfall 9: Forgetting Error Handling
Network requests and async operations can fail.
Wrong:
GM_xmlhttpRequest({
url: 'https://api.example.com/data',
onload: (r) => {
const data = JSON.parse(r.responseText); // Crashes if invalid JSON
process(data);
}
});
Right:
GM_xmlhttpRequest({
url: 'https://api.example.com/data',
onload: (r) => {
try {
const data = JSON.parse(r.responseText);
process(data);
} catch (e) {
console.error('Failed to parse response:', e);
}
},
onerror: (e) => {
console.error('Request failed:', e);
},
ontimeout: () => {
console.error('Request timed out');
}
});
Pitfall 10: Global Variable Pollution
Variables leak into page scope without IIFE wrapper.
Wrong:
// ==UserScript==
// ...
// ==/UserScript==
var myData = 'secret'; // Visible to page as window.myData!
function process() { ... } // Visible to page
Right:
// ==UserScript==
// ...
// ==/UserScript==
(function() {
'use strict';
var myData = 'secret'; // Private to script
function process() { ... } // Private to script
})();
Pitfall 11: Wrong Timing with @run-at
Script runs before elements exist.
Wrong:
// @run-at document-start
document.querySelector('#header').remove(); // null - DOM doesn't exist yet!
Right:
// @run-at document-end
document.querySelector('#header').remove(); // Works
Or wait for DOM:
// @run-at document-start
document.addEventListener('DOMContentLoaded', () => {
document.querySelector('#header').remove();
});
Pitfall 12: Cross-Browser Differences
Firefox and Chrome behave differently.
Firefox-only features:
// cloneInto and exportFunction only exist in Firefox
if (typeof cloneInto !== 'undefined') {
unsafeWindow.myData = cloneInto(data, unsafeWindow);
} else {
unsafeWindow.myData = data; // Chrome
}
Manifest V3 limitations (Chrome):
// @webRequest doesn't work in Chrome MV3
// Use alternative approaches or inform user
Pitfall 13: Hardcoded Selectors
Page structure changes break scripts.
Fragile:
document.querySelector('div.sc-1234abcd > div:nth-child(3) > span');
Robust:
// Use stable attributes
document.querySelector('[data-testid="username"]');
document.querySelector('[aria-label="Close"]');
// Or multiple fallbacks
const element = document.querySelector('#username') ||
document.querySelector('[data-user]') ||
document.querySelector('.profile-name');
Pitfall 14: Not Testing in Target Browser
Scripts that work in Chrome may break in Firefox.
Before deploying:
- Test in Chrome
- Test in Firefox
- Test in private/incognito mode
- Test with page's CSP (disable extensions to check)
Quick Diagnostic Checklist
When a script doesn't work:
[ ] Console shows errors? (F12 → Console)
[ ] Script is enabled in Tampermonkey dashboard?
[ ] @match pattern matches current URL?
[ ] Required @grant statements present?
[ ] @connect includes target domains?
[ ] Element exists when script runs?
[ ] Using async correctly (callbacks/await)?
[ ] Browser-specific features used correctly?