January 19, 2026 8 min read by Mykola Samila

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:

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);
  }
});

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:

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 touch

You can also see the ADHD Website Blocker case study for context on the project itself.

← All articles  ·  Portfolio