← Back
Architecture

Migrating a monolithic userscript to modular Manifest V3

I inherited a userscript nobody could maintain. I rebuilt it as a browser extension.

What it does

Large operations team. They investigate cases across multiple internal platforms — ticketing, fulfillment, compliance. A single case touches several tools. The extension eliminates the manual glue between them: auto-populates fields, pulls data inline, flags errors before submit, handles bulk actions.

Throughput improved significantly. The extension didn't just speed things up — it made the current process (more complex than when the team was smaller) viable at the scale it runs today.

What I inherited

One file. Loaded on every page. Global state. CSS built as string arrays. Version checking done by polling an external source and comparing values to the running version.

The author left. I had to decide whether to keep patching it or start over.

Inherited

  • 1 file, loaded on every URL
  • Shared global state
  • One crash = total failure

Rebuilt

  • 40+ modules, per-domain loading
  • Isolated state per feature
  • Failures contained to their module

What I built

flowchart TD
    MAN[manifest.json]
    BG[background]
    CS[content-scripts]
    DATA[data]
    OTHER[rules + popup + options + styles]
    SW[service-worker.js]
    AUTH[auth-signing.js]
    CORE[core - 9 files]
    MODS[modules - 40 files]
    MAIN[main.js]
    SYNC[data-sync - 11 sub-modules]
    CONF[8 config files]

    MAN --> BG
    MAN --> CS
    MAN --> DATA
    MAN --> OTHER
    BG --> SW
    BG --> AUTH
    CS --> CORE
    CS --> MODS
    CS --> MAIN
    MODS --> SYNC
    DATA --> CONF
          
extension/
├── background/
│   ├── service-worker.js     # message router, API proxy
│   └── auth-signing.js       # request signing for secured APIs
├── content-scripts/
│   ├── core/                 # 9 shared utilities
│   ├── modules/              # 40 standalone feature modules
│   │   └── data-sync/       # 11 sub-modules (complex feature)
│   └── main.js              # feature guard + init
├── data/                     # 8 config files
├── rules/                    # declarativeNetRequest rules
├── popup/                    # extension popup UI
├── options/                  # settings page
├── styles/                   # 5 CSS files
└── libs/                     # 3 vendored libraries

11 content script groups target specific domains. The primary tool loads 30+ modules + the 11-file data-sync sub-system. Others load 1–8 each. They can't affect each other.

How modules talk

sequenceDiagram
    participant M as Module
    participant SW as Service Worker
    participant API as External Service
    M->>SW: request by name
    SW->>API: authenticated call
    alt ok
        API-->>SW: data
        SW-->>M: result
    else auth expired
        SW-->>M: re-authenticate
    end
          

No module knows where data lives. They describe what they need. The service worker figures out the rest.

Migration

flowchart LR
    subgraph "Month 1"
        M1A[Extension ships\n3 core modules]
        M1B[Userscript still\nruns everything else]
    end
    subgraph "Month 2-3"
        M2A[Features migrate\none at a time]
        M2B[Both outputs diffed\non same inputs]
    end
    subgraph "Month 4"
        M3A[Userscript retired]
        M3B[Rollback removed]
    end
    M1A --> M2A --> M3A
          

Both ran in parallel. Features moved one at a time. Data outputs diffed against the old version until they matched.

What I learned

The original worked fine — until it didn't. One person's code that only one person understood, running on every page, holding everything in globals. When it broke, everything broke. When the author left, nobody could fix it.

Now someone else can open it and know where to look. When extension access was briefly disrupted across a large part of the organization, it was escalated as critical within hours. The tool had become infrastructure — and infrastructure needs to be ownable by more than one person.