Skip to content

How I Built ResistGate: A Chrome Extension That Makes You Earn Distractions

Chrome extensionsMay 17, 2026

Real products need friction by design — ResistGate makes you type before distraction, not just block URLs.

How I Built ResistGate: A Chrome Extension That Makes You Earn Distractions

Every other post in this series teaches a concept with examples. This one is different — it's the full story of building a real product. No hypotheticals. Every decision, mistake, and edge case is from my actual experience shipping ResistGate.

Nobody else can write this post because nobody else built it. That's the point.

The Problem

I kept opening Reddit and YouTube on autopilot. Mid-sentence while writing code. Between test runs. During compile waits. Not because I wanted to — because the reflex was faster than the thought.

Site blockers exist. Cold Turkey, LeechBlock, StayFocusd. I tried them. The problem: total lockouts feel punishing. I'd either rage-disable them or find workarounds. The friction was binary — everything or nothing.

I wanted something in between. Not a wall. A gate. One that makes you earn access through effort so the reflex breaks, but doesn't lock you out if you genuinely want a break.

That's ResistGate: graduated friction challenges before accessing distracting sites. Pass the challenge, get 15 minutes. Timer expires, gate returns.

The Architecture

ResistGate uses three of the four extension components:

Service Worker (the brain)

Handles:

  • Navigation interception via chrome.webNavigation.onBeforeNavigate
  • Challenge state management (which sites are blocked, active access windows)
  • Timer management via chrome.alarms for access window expiry
  • Storage coordination between popup and content script

The critical design choice: no global variables for state. The service worker terminates when idle. Every piece of state — blocked sites list, active sessions, challenge completion timestamps — lives in chrome.storage. When the worker wakes up on a navigation event, it reads state from storage, makes a decision, and acts.

This is exactly the pattern from the storage post. I learned the hard way before writing that post.

Content Script (the gate)

When the service worker detects navigation to a blocked site, it injects the challenge UI into the page. The content script:

  • Replaces the page content with the challenge interface
  • Renders the typing challenge (Easy/Moderate/Hard)
  • Validates input character by character with 100% accuracy on Hard mode
  • Sends completion status back to the service worker via message passing

The content script runs in an isolated world — it can manipulate the DOM but can't access the page's JavaScript. This is important because the blocked page might have scripts that try to redirect or auto-play content. The isolation prevents interference.

Popup (the control panel)

Settings UI for:

  • Managing the blocked sites list
  • Choosing challenge difficulty
  • Viewing analytics (time saved, challenges completed, weekly discipline score)
  • Enabling commitment mode (1–24 hour lockout of the settings themselves)

Built with vanilla TypeScript, not React. The popup is simple enough that React's overhead wasn't justified. A few DOM updates on storage changes — the useStorageValue hook from the React post would work, but vanilla was faster to build and smaller to ship.

The Hard Decisions

Interception Strategy

Two options for blocking navigation:

Option A: chrome.webNavigation.onBeforeNavigate — Fires before the page loads. You can redirect to a challenge page.

Option B: chrome.declarativeNetRequest — MV3's declarative replacement for webRequest. Rules-based, no code execution needed. Faster, but less flexible.

I chose Option A. declarativeNetRequest is great for static rules (ad blocking), but ResistGate needs dynamic behavior — the block list changes, access windows open and close, and the challenge type varies. The imperative API gives full control.

The trade-off: webNavigation requires the <all_urls> host permission. There's no way around it — ResistGate needs to intercept navigation to ANY site the user adds to their block list. I can't predict which domains upfront.

This is the exact scenario from the permissions post where <all_urls> is justified. The Web Store listing explains why, and the privacy policy confirms no data leaves the device.

Challenge Validation

Hard mode requires typing a five-paragraph passage with 100% accuracy. No paste allowed.

The naive approach: compare final input to expected text. Problem: users get frustrated typing 500 characters only to discover a typo at position 23.

My approach: real-time character-by-character validation. Each keystroke is checked against the expected character at that position. Wrong characters are highlighted immediately. The cursor can't advance past an error.

// Simplified version of the validation logic
function validateInput(input: string, expected: string): ValidationResult {
  for (let i = 0; i < input.length; i++) {
    if (input[i] !== expected[i]) {
      return { valid: false, errorPosition: i, correctCount: i };
    }
  }
  return {
    valid: input.length === expected.length,
    errorPosition: null,
    correctCount: input.length,
  };
}

Preventing Paste

Blocking paste in the challenge input is critical — otherwise the friction disappears.

challengeInput.addEventListener("paste", (e) => e.preventDefault());
challengeInput.addEventListener("drop", (e) => e.preventDefault());

But users found a workaround: browser extensions that auto-type clipboard content. I added a typing speed check — if characters arrive faster than humanly possible (~20ms between characters), they're rejected.

Access Window Management

When a user completes a challenge, they get 15 minutes of unrestricted access to that specific site. Implementation:

  1. Store { domain, expiresAt } in chrome.storage.local
  2. Create a chrome.alarms alarm for the expiry time
  3. On navigation events, check if an active window exists for the domain
  4. When the alarm fires, remove the window and re-enable blocking

The alarm-based approach survives service worker termination. setTimeout wouldn't — it dies when the worker goes idle.

Commitment Mode

The hardest feature to build correctly. Commitment mode locks the extension settings for 1–24 hours. The user can't modify their block list, change difficulty, or disable the extension during this period.

The edge cases:

  • Extension updates during commitment. Chrome sometimes restarts extensions on update. The service worker re-initializes, but commitment state persists in storage.
  • System clock manipulation. Users tried changing their system clock to expire commitment mode. Fix: store the commitment duration AND the start timestamp. Calculate remaining time on each check.
  • Uninstall/reinstall. Can't prevent this. If someone really wants to bypass ResistGate, they'll uninstall it. The goal is friction, not prison.

The Discipline Score

Weekly discipline score = challenges completed ÷ (challenges completed + times the extension was disabled). Stored in chrome.storage.local, calculated on each challenge completion and each settings toggle.

This was a product decision more than a technical one. Showing a score gamifies discipline. Users told me they felt bad seeing it drop, which made them think twice before disabling the extension.

Edge Cases That Broke Things

Chrome internal pages. Content scripts can't inject into chrome://, chrome-extension://, or edge:// pages. The service worker needs to check the URL before attempting injection — otherwise chrome.scripting.executeScript throws.

Multiple tabs of the same blocked site. Each tab gets its own access window check. If you earn access in Tab A and open the same site in Tab B, Tab B still needs a challenge. This was a deliberate decision — each tab is a separate impulse check.

Redirect chains. Some sites (like t.cotwitter.com) involve redirects. The navigation interception fires on the initial URL, but the content script needs to handle the final destination. I listen to chrome.webNavigation.onCompleted to catch the final URL.

Extension popup on mobile Chrome. Chrome extensions don't work on mobile. This sounds obvious, but users asked. The answer is always "extensions are desktop only."

What I'd Do Differently

Start with React. The popup grew more complex than expected. Vanilla DOM manipulation with 10+ interactive elements became spaghetti. If I rebuilt today, I'd use React from day one (with the setup from the React post).

Better onboarding. First-time users don't understand the challenge system until they experience it. I should've added an interactive tutorial on install instead of a text-based welcome page.

Analytics from day one. I added tracking too late. Basic anonymized usage metrics (challenge completion rate, average session length) would've informed product decisions earlier.

The Numbers

  • Development time: ~3 weeks of evenings and weekends
  • Lines of TypeScript: ~2,400
  • Service worker: ~800 lines
  • Content script (challenge UI): ~1,000 lines
  • Popup: ~600 lines
  • Files: 18

Not a large codebase. The complexity is in the state management and edge cases, not volume.

What's Next

If you want to build something like this:

  1. Start hereBuild Your First Chrome Extension with TypeScript gets the foundation right.
  2. Get the architectureHow Chrome Extensions Actually Work in 2026 gives you the mental model.
  3. Publish itHow to Publish a Chrome Extension to the Web Store is the final checklist.

Or try ResistGate yourself. If you're reading a blog post about Chrome extensions, you probably have tabs you shouldn't.


This is part of the Chrome Extensions from Zero to Product series. This post is the reason the series exists — every other post teaches what I learned building this.

Notes from the build

Get more AI engineering insights

Follow the work: AI tools, browser products, product decisions, and honest lessons from the build.

By subscribing, you agree to receive Orlando's emails. No spam. Unsubscribe anytime.

How I Built ResistGate: A Chrome Extension That Makes You Earn Distractions | Orlando Ascanio