Table of Contents
I built the ADHD Website Blocker — a free Chrome extension for blocking distracting websites with flexible rules and timers. Building it taught me more about Chrome extension quirks than any documentation did. Here's what I'd tell myself before starting.
MV3 vs MV2: what changed and why
Manifest V3 is Chrome's overhaul of the extension API, enforced for all new extensions since 2023. The two biggest changes:
- Background pages → Service workers. Extensions can no longer run persistent background processes. Service workers terminate when idle and restart on events.
- webRequest (blocking) → Declarative Net Request. Extensions can no longer intercept and modify requests arbitrarily. Instead, you declare rules and Chrome enforces them natively.
Google's stated reasons are security and performance. The real-world consequence is that some sophisticated MV2 ad blockers couldn't be ported directly. For simpler use cases like website blocking, the new APIs are actually cleaner.
The manifest.json
This is the entry point for everything. Here's a working MV3 manifest for a website blocker:
{
"manifest_version": 3,
"name": "ADHD Website Blocker",
"version": "1.0.0",
"description": "Block distracting websites with flexible rules and timers.",
"permissions": [
"declarativeNetRequest",
"declarativeNetRequestWithHostAccess",
"storage",
"alarms",
"notifications"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
Request only the permissions you actually use. The Chrome Web Store reviewer will ask why you need each permission, and <all_urls> host permission requires a written justification.
Service workers: the biggest gotcha
The single most confusing thing about MV3 is that service workers don't persist. They can terminate after 30 seconds of inactivity. This means you cannot store state in global variables — they'll be gone when the service worker restarts.
// WRONG: this variable is lost when service worker terminates
let blockingRules = [];
chrome.runtime.onInstalled.addListener(() => {
blockingRules = loadFromSomewhere(); // pointless
});
// CORRECT: always read from storage
chrome.runtime.onInstalled.addListener(async () => {
const { rules } = await chrome.storage.local.get('rules');
await applyRules(rules ?? []);
});
For timers, don't use setTimeout or setInterval — they die with the service worker. Use the chrome.alarms API instead, which persists across service worker restarts:
// Set a timer that survives service worker termination
await chrome.alarms.create('unblock-timer', {
delayInMinutes: 30,
});
// Listen for the alarm firing (in any service worker instance)
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'unblock-timer') {
await removeAllBlockingRules();
chrome.notifications.create({
type: 'basic',
title: 'Timer expired',
message: 'Your blocking session has ended.',
iconUrl: 'icons/icon48.png',
});
}
});
Declarative Net Request for blocking
The Declarative Net Request API lets you define rules that Chrome enforces natively — no script intercepts requests. Rules can be static (bundled with the extension) or dynamic (added at runtime).
For a website blocker, dynamic rules are what you want — users define which sites to block:
async function applyBlockingRules(domains) {
// Remove all existing dynamic rules first
const existingRules = await chrome.declarativeNetRequest.getDynamicRules();
const existingIds = existingRules.map(r => r.id);
const newRules = domains.map((domain, index) => ({
id: index + 1, // IDs must be positive integers
priority: 1,
action: { type: 'redirect', redirect: { extensionPath: '/blocked.html' } },
condition: {
urlFilter: `||${domain}^`, // matches domain and all subdomains
resourceTypes: ['main_frame'], // only top-level navigation
},
}));
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: existingIds,
addRules: newRules,
});
}
The urlFilter syntax follows a specific pattern. ||example.com^ matches example.com, www.example.com, and any subdomain. For wildcard paths, use *.
Storage API
Extensions have access to chrome.storage.local (up to 10MB, persisted locally) and chrome.storage.sync (synced across devices, limited to 100KB). For a privacy-focused extension, use local only.
// Save rules
async function saveRules(rules) {
await chrome.storage.local.set({ rules });
}
// Load rules
async function getRules() {
const { rules } = await chrome.storage.local.get('rules');
return rules ?? [];
}
// Listen for changes (useful for syncing popup UI with background changes)
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.rules) {
updatePopupUI(changes.rules.newValue);
}
});
Building the popup UI
The popup is just an HTML page with a <script> that communicates with the service worker via chrome.runtime.sendMessage. Keep the popup stateless — read from storage on open, write to storage on change.
// popup.js
document.addEventListener('DOMContentLoaded', async () => {
const rules = await getRules(); // reads from chrome.storage.local
renderRuleList(rules);
});
async function addRule(domain) {
const rules = await getRules();
const updated = [...rules, { domain, enabled: true }];
await saveRules(updated);
// Tell the background to update DNR rules
chrome.runtime.sendMessage({ type: 'RULES_UPDATED', rules: updated });
renderRuleList(updated);
}
// background.js — listen for popup messages
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'RULES_UPDATED') {
applyBlockingRules(message.rules.map(r => r.domain));
}
sendResponse({ ok: true });
return true; // required to keep message channel open for async responses
});
Surviving the Chrome Web Store review
Reviews typically take 1–3 days. Here's what slows them down or causes rejection:
- Unused permissions. Remove any permission you don't use. Every permission needs justification.
- Remote code execution. MV3 doesn't allow loading scripts from external URLs. All code must be bundled with the extension.
- Missing privacy policy. If your extension collects any data (even just storing user preferences locally), you need a privacy policy page. Even for a "no data collection" extension, it's worth having one to state that explicitly.
- Vague description. Be specific about exactly what your extension does, what permissions it needs and why. The reviewer reads your description and store listing.
- Icon quality. Submit all four icon sizes (16, 48, 128, and 440×280 for the store tile). Low-resolution or missing icons cause delays.
My extension was approved on the first submission. The privacy policy — even a simple one-pager stating no data collection — seemed to help significantly.
Need help building a Chrome extension?
Get in touchYou can also see the ADHD Website Blocker case study for context on the project itself.