DEV Community

Ishan Naik
Ishan Naik

Posted on Originally published at github.com

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

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.

DoomBreaker at high damage, with cracks and visual distortion

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.

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.

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.

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.

Thresholds turn arithmetic into discomfort

flowchart TD
    W[Wheel distance normalized to pixels] --> A[Add damage and clamp to 0 through 1]
    V[Shorts or Reels video advancement] --> A
    A --> D[Shared damage meter]
    I[Idle time] --> H[Apply healing using elapsed time]
    H --> D
    D --> P[Persist damage and synchronize tabs]
    D --> R[250 ms visual update loop]
    R --> B{Damage at least 0.30?}
    B -->|Yes| F[Blur and grayscale with vignette]
    R --> C{Damage at least 0.60?}
    C -->|Yes| G[Reveal SVG cracks and enable CSS glitch]
    R --> S{Damage at least 0.90?}
    S -->|Yes| K[Enable shake]
    D --> N{Damage at least 0.995?}
    N -->|Yes| M[Message service worker to install feed blocks]
    D --> U{Damage below 0.85?}
    U -->|Yes| O[Message service worker to remove feed blocks]

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.

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.

This condensed excerpt shows the CSS/SVG mechanism. The caller supplies d from the meter and a path from the seeded geometry generator:

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);
}
Enter fullscreen mode Exit fullscreen mode
#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; }
}
Enter fullscreen mode Exit fullscreen mode

The full effects.css includes more glitch bursts, damage-scaled shake, and reduced-motion overrides for both animations.

I append the overlay to document.documentElement, outside the body I filter. This placement preserves crisp crack strokes above blurred content. With pointer-events: none, I leave links and buttons reachable through the overlay.

Filtering the body changes positioning behavior for some fixed descendants. A header can stop behaving like a viewport-fixed header. In this project I accept that distortion as part of the damaged interface, but I would not copy the technique into an ordinary UI without checking its positioning consequences.

A shader needs something to sample

A WebGL displacement shader could offset texture coordinates by scanline, time, and damage:

precision mediump float;
uniform sampler2D uTexture;
uniform float uDamage;
uniform float uTime;
varying vec2 vUv;

void main() {
  float band = floor(vUv.y * 90.0);
  float noise = fract(sin(band * 12.9898 + floor(uTime * 8.0))
                      * 43758.5453);
  float strength = max(0.0, uDamage - 0.6);
  float offset = (noise - 0.5) * 0.04 * strength;
  vec2 uv = clamp(vUv + vec2(offset, 0.0), 0.0, 1.0);
  gl_FragColor = texture2D(uTexture, uv);
}
Enter fullscreen mode Exit fullscreen mode

This fragment shader illustrates the proposed rendering route; DoomBreaker does not ship it. A developer would also need vertex-shader plumbing, uniforms, and a texture source.

The texture source creates the hard problem. A WebGL canvas cannot sample an arbitrary live DOM tree as a texture. Capturing or reconstructing the page adds permission, fidelity, and update-cost questions, especially for cross-origin images and playing video. I use CSS clipping and transforms in the current extension to distort the host DOM without building that capture pipeline.

Healing: distinguish the model from the constants

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
}
Enter fullscreen mode Exit fullscreen mode

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.

MV3: block the next page of posts

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().

This condensed snippet follows the repository's precise-rule generation, including its six reserved IDs:

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) : []
  });
}
Enter fullscreen mode Exit fullscreen mode

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:

Reserved IDs    Endpoint family
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
Enter fullscreen mode Exit fullscreen mode

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.

flowchart LR
    J[Repository config/sites.json] --> F[Service worker fetch]
    F --> V{Valid configuration?}
    V -->|Yes| C[Cache data and fetch timestamp in storage]
    V -->|No or offline| K[Keep cached or bundled configuration]
    C --> S[Content scripts update site matching]
    C --> N[Worker generates network rules]
    K --> S
    K --> N

I use the same broad remote-asset pattern as filter-list tools such as uBlock Origin: distribute changing declarative data apart from executable extension code. A maintainer can edit endpoint patterns without submitting a new executable package for each URL change.

The current file contains path and host rules, not arbitrary DOM selectors. The extension still ships its parsing and matching code in the package. Remote JSON does not grant permission to download and execute JavaScript; Chrome's remote-hosted-code guidance draws that distinction.

A six-hour alarm is a refresh cadence, not a delivery guarantee. A sleeping browser, lost network access, or an invalid update delays adoption. Remote configuration also cannot expand the six-rule budget in preciseRules(); adding a seventh filter to JSON requires a code change to avoid silent omission.

Inspect the real pipeline

DoomBreaker blur stage

The blur stage preserves the page structure while reducing readability.

The repository includes unit tests and a visual harness. These are commands for reproducing the development workflow, not a report that I ran them for this article:

npm install
npm test
python -m http.server 8377

Visual harness:
http://localhost:8377/test/harness.html

Unpacked extension:
chrome://extensions
  Developer mode
  Load unpacked
  Select the repository directory
Enter fullscreen mode Exit fullscreen mode

The harness lets you drive damage thresholds through the rendering pipeline. To inspect network blocking, use the loaded extension on a supported site and watch the next matching request in DevTools. Inspect the service worker's dynamic rules as well: visible damage alone does not establish that Chrome installed a block.

I kept the central pieces separate: meter arithmetic in meter.js, input and rendering orchestration in content.js, declarative site data in config/sites.json, and rule installation in background.js. That separation lets a maintainer tune the discomfort curve without rewriting Chrome integration, or repair a changed feed endpoint without touching crack geometry.

The remaining engineering questions are concrete: choose a healing curve that matches the intended break length, reconcile simultaneous tab input, narrow shared API filters, and keep network-rule removal reliable after worker restarts. Those choices determine how the extension behaves after the screenshot, when someone stops scrolling and expects their page to recover.

Source: Ishan Naik / DoomBreaker.

Top comments (0)