Breaking the DOM to Stop Doomscrolling: Glitch Shaders, Scroll Physics, and Chrome MV3 Network Throttling
DEV Community

Breaking the DOM to Stop Doomscrolling: Glitch Shaders, Scroll Physics, and Chrome MV3 Network Throttling

Overview and Premise

Scroll through a feed long enough and DoomBreaker makes the page harder to read. Color drains. Glass cracks spread across the viewport. At full damage, Chrome blocks the requests that would fetch another batch of posts. I built DoomBreaker around a behavioral premise: introduce discomfort into the interface you keep consuming. The base degradation mode leaves your existing content on screen and restores readability when you stop. You do not dismiss a guilt popup to return to an untouched feed. A repository screenshot of the damaged interface, rather than a synthetic rendering. A source note matters before the engineering details. The design brief describes exponential healing near 0.8% per second and WebGL displacement shaders. The current repository implements a different tuning: linear healing after 20 seconds of idle time, plus CSS clip‑path glitches. I distinguish those implementations below. The title's network throttling refers to stopping matching feed requests, not imposing a bandwidth limit. Chrome's rule action here is block. The current extension also includes a companion cat, optional daily budgets, and a Gatekeeper mode that enforces timed breaks. Those modes add blocking behavior beyond the original discomfort premise. This article follows the damage, rendering, and network pipeline.

Damage Meter and Input Handling

One damage value across the feed DoomBreaker uses a scalar damage value, $d \in [0,1]$. In meter.js, I keep the arithmetic separate from Chrome and DOM APIs. A content script supplies input; the meter updates a plain object with d, last, and lastTick.

For wheel input, the current calculation is:
$$ d_{next}=\min\left(1, d+\frac{|\Delta y|,s}{40000}\right) $$
Here, $s$ represents the popup sensitivity setting. At sensitivity 1, 40,000 accumulated pixels take a fresh meter to full damage. Reversing scroll direction still adds damage because I count absolute distance. Wheel events report three units. In content.js, I treat pixel deltas as pixels, multiply line deltas by 16, and multiply page deltas by the viewport height. That 16‑pixel conversion is a heuristic, not a measurement of the host page's line height. I register passive input listeners and update state inside them. A 250 ms loop handles visual changes. Keeping DOM mutations out of wheel handlers avoids adding layout work to each burst of input.

Keyboard and touch scrolling need another route. I compare successive window.scrollY values, but ignore scroll events for 300 ms after a wheel event. A wheel gesture often produces both events; without that exclusion, I would charge the same gesture twice. The fallback also excludes deltas of 2,000 pixels or more. That choice avoids charging some large jumps, but can miss legitimate movement.

Video Advancement Handling

Short videos need a different unit. YouTube Shorts and Instagram Reels can advance without moving the document through a meaningful pixel distance. I count qualifying video‑path changes instead. The current meter adds 0.06 * sensitivity per advancement, so 17 advancements reach full damage at sensitivity 1. The intended product tuning describes Shorts and Reels as accumulating damage about three times faster than ordinary scrolling. The implementation does not multiply wheel damage by three. It assigns videos a separate unit cost. A time‑based comparison depends on your scroll speed and how long you watch each video. The initial route does not count as an advancement. Subsequent qualifying route changes do. That distinction avoids charging damage just because you opened a Shorts URL.

Storage Synchronization and Multi‑tab Considerations

I store the meter under the shared damage.all key in chrome.storage.local. Switching from X to Reddit retains damage. Content scripts watch storage changes and adopt newer timestamped writes. That synchronization has a limit: last‑write‑wins adoption does not provide an atomic sum of simultaneous inputs from several tabs. A shared key prevents a fresh budget per site, but a rigorous multi‑tab accumulator would need a serialized owner for damage updates.

Visual Feedback Thresholds and Effects

Thresholds turn arithmetic into discomfort:

  • Wheel distance normalized to pixels → Add damage and clamp to 0 through 1
  • Shorts or Reels video advancement → Add damage and clamp to 0 through 1
  • Shared damage meter → Persist damage and synchronize tabs
  • 250 ms visual update loop → Evaluate thresholds

At 0.30, I enable blur and grayscale on the body. A separate radial‑gradient overlay darkens the edges; the CSS starts its opacity ramp below the main blur threshold, at 0.25.
At 0.60, I reveal SVG cracks and enable the glitch animation. More cracks appear at 0.70, 0.80, 0.90, and 0.97.
At 0.90, I add shake.
At 0.995, I request a network block. I leave that block active until damage falls below 0.85. Those separate entry and exit thresholds provide hysteresis. A meter hovering near full damage should not alternate between adding and removing Chrome rules. The cracks have their own recovery behavior: I clear them below 0.50. The user sees cracks persist through part of the healing interval instead of watching the geometry toggle around 0.60.

SVG Crack Generation and CSS/JS Implementation

Generate the cracks once, reveal them later. I generate three impact points with a seeded pseudorandom generator. Each impact gets four to six rays; each ray gets three to five jittered segments. I create two SVG paths per ray: a broad, dim backing stroke and a narrower bright stroke. I generate that geometry once per page‑session crack set. During progression, I reveal existing paths with stroke-dashoffset. I do not generate another crack on every frame.

const SVG_NS = 'http://www.w3.org/2000/svg';
function addCrack(svg, pathData) {
  const path = document.createElementNS(SVG_NS, 'path');
  path.setAttribute('d', pathData);
  path.setAttribute('fill', 'none');
  path.setAttribute('stroke', 'rgba(255,255,255,.75)');
  path.setAttribute('stroke-width', '1.5');
  svg.appendChild(path);
  const length = Math.max(1, path.getTotalLength());
  path.style.strokeDasharray = String(length);
  path.style.strokeDashoffset = String(length);
  path.style.transition = 'stroke-dashoffset 0.7s ease-out';
  requestAnimationFrame(() => {
    path.style.strokeDashoffset = '0';
  });
}

function applyDamage(d) {
  const root = document.documentElement;
  root.style.setProperty('--d', String(d));
  root.classList.toggle('db-blur', d >= 0.30);
  root.classList.toggle('db-glitch', d >= 0.60);
}
#db-overlay {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 2147483647;
}
html.db-blur body {
  filter: blur(calc((var(--d, 0) - 0.3) / 0.7 * 5px))
          grayscale(calc((var(--d, 0) - 0.3) / 0.7));
}
@keyframes db-glitch {
  0%, 12%, 100% { clip-path: none; transform: none; }
  7% { clip-path: inset(8% 0 61% 0); transform: translateX(-6px); }
  9% { clip-path: inset(55% 0 12% 0); transform: translateX(5px); }
}
html.db-glitch body {
  animation: db-glitch 2.5s steps(1, end) infinite;
}
@media (prefers-reduced-motion: reduce) {
  html.db-glitch body { animation: none; }
}

Healing Models (Exponential vs Linear)

For the proposed exponential model, you would apply:
$$ d(t+\Delta t)=d(t)e^{-k\Delta t} $$
Taking a 0.8% relative reduction per second gives $k=-\ln(0.992)\approx0.00803$. Starting at 1, that model reaches the 0.85 unblock boundary after about 20.2 seconds of healing. It approaches zero without reaching it.

The README describes healing near 0.8% per second, but the current meter uses a linear subtraction:

CFG : {
  BUDGET_PX : 40000,
  VIDEO_HIT : 0.06,
  HEAL_PER_SEC : 1 / 3600,
  IDLE_MS : 20000
}

After 20 seconds without input, the meter subtracts HEAL_PER_SEC * healMultiplier * elapsedSeconds. At the default multiplier, that rate equals about 0.0278 percentage points per second. Dropping from 1 to 0.85 takes about nine minutes of healing, plus the idle delay; the strict below‑0.85 check releases on a subsequent tick. I calculate elapsed time from timestamps instead of subtracting a fixed amount per callback. Browser scheduling delays should change update cadence, not define the healing rate. These constants deserve attention during tuning because a 20‑second recovery and a nine‑minute recovery produce different user experiences.

Network Blocking via DeclarativeNetRequest (MV3)

A blur cannot stop someone from scrolling. At full damage, I ask the service worker to install dynamic declarativeNetRequest rules. The content script sends a db-feedkill message with the desired state and hostname. In background.js, I generate rules from config.kill and call updateDynamicRules().

const RULE_IDS = [101, 102, 103, 104, 105, 106];
function preciseRules(config) {
  const rules = [];
  let id = 101;
  for (const filters of Object.values(config.kill || {})) {
    for (const urlFilter of filters) {
      if (id > 106) break;
      rules.push({
        id: id++,
        priority: 1,
        action: {type: 'block'},
        condition: {urlFilter, resourceTypes: ['xmlhttprequest']}
      });
    }
  }
  return rules;
}
async function setFeedBlocked(on, config) {
  await chrome.declarativeNetRequest.updateDynamicRules({
    removeRuleIds: RULE_IDS,
    addRules: on ? preciseRules(config) : []
  });
}

The extension declares storage, declarativeNetRequest, and alarms permissions in its MV3 manifest. Chrome performs the matching and blocking; I do not keep a JavaScript request listener alive to intercept each feed fetch. The current filters include:

  • 101, 102 → x.com/i/api/graphql and twitter.com/i/api/graphql
  • 103 → reddit.com/svc/shreddit/
  • 104 → instagram.com/graphql/query
  • 105 → youtube.com/youtubei/v1/reel/
  • 106 → linkedin.com/voyager/api/feed

Damage 0.995 → install rules → future matching requests fail
Damage < 0.85 → remove rules → later requests can proceed

These filters match endpoint families, not a verified feed operation within each GraphQL payload. A broad GraphQL filter can affect other features that share that URL. I also install the precise rules as a group, rather than limiting them to the tab that requested the block. For an unknown host, the worker adds a generic rule for that host's XHR‑class requests. That fallback extends coverage but can block unrelated API calls on the same site. The current host‑hash ID allocation also permits collisions; it does not guarantee a unique ID for every hostname. Chrome documents that dynamic rules persist across browser sessions and extension upgrades. Worker shutdown does not remove them. That persistence makes cleanup and recovery important: after healing, the extension must remove the rules, not just change a variable in the content script. Blocking a request does not erase posts already in memory, stop an in‑flight response, or force a site's feed component to retry. After removal, the site may need another user action to fetch again. The network gate controls future matching requests, not the entire application lifecycle.

Remote Configuration Without Remote JavaScript

A feed extension depends on URLs and routing conventions that site owners can change. I keep those rules in config/sites.json: hostname regexes, active path prefixes, video path patterns, and network filters. The worker refreshes configuration on install and browser startup, then through a six‑hour alarm. It loads cached data first, validates fetched JSON, stores a version and fetch timestamp, and retains the cached or bundled configuration when fetching fails.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.