I Built a Chrome Extension to Stop Writing the Same LinkedIn Messages

TL;DR: I got tired of copy-pasting the same LinkedIn connection messages with minor tweaks, so I built a Chrome extension that detects profile info and auto-fills personalized messages. The interesting part wasn't the extension itself — it was learning to fight LinkedIn's DOM, surviving a 4,000-line over-engineering disaster, and rebuilding something simple that I actually use every day.


Why I Built This

Job searching as a grad student means sending a lot of LinkedIn messages. Connection requests, recruiter outreach, referral asks, follow-ups — each one slightly different but following the same pattern. I'd open a profile, click Connect, click "Add a note," then manually type out a message swapping in their name, company, and role.

After the 50th time, I thought: this is literally template substitution. A browser extension could do this in seconds.

The Architecture

The extension is surprisingly simple in concept: a content script that injects into LinkedIn pages, a popup UI for configuration, and Chrome's storage API for persistence. No background service worker, no external servers, no API calls. Everything runs locally in the browser.

There are four message types, each with different character limits:

// content.js — quick action definitions
const QUICK_ACTIONS = [
  { id: "newConnection", label: "New Connection", maxChars: 300 },
  { id: "recruiter",     label: "Recruiter",      maxChars: 300 },
  { id: "firstReferral", label: "First Referral",  maxChars: 500 },
  { id: "followUpReferral", label: "Follow-up",    maxChars: 500 },
];

A floating button (the 🤝 FAB) appears on every LinkedIn page. Click it, pick a message type, and the extension either copies the message to your clipboard or inserts it directly into the message box — depending on whether you're on a profile page or in LinkedIn messaging.

The Hard Parts

Fighting LinkedIn's DOM

LinkedIn is a single-page app that constantly shuffles its CSS class names and markup. If you hardcode a selector like .pv-text-details__left-panel h1, it might work today and break next week.

My solution was a fallback-chain approach: try multiple selectors for each element, and use validators to confirm you actually found the right thing.

// content.js — resilient element detection
async findAddNoteButton() {
  return this.findElement(
    [
      'button[aria-label="Add a note"]',
      "button[data-test-modal-add-note-btn]",
      '.artdeco-modal button[aria-label*="note"]',
    ],
    {
      maxAttempts: 5,
      delay: 100,
      validator: (el) =>
        el.textContent.includes("Add a note") ||
        el.getAttribute("aria-label")?.includes("Add a note"),
    }
  );
}

The validator is key. Without it, you might grab a random button that happens to match a selector. The aria-label check acts as a semantic confirmation — even if LinkedIn renames its CSS classes, the accessibility attributes tend to stay stable.

For profile data extraction, I used the same pattern. Parsing someone's name from the page header requires trying four different selectors and then validating with a regex to make sure you didn't accidentally grab a heading that says "Connect" or "Follow":

// content.js — name extraction with validation
if (/^[A-Za-z\s\.\-']{2,50}$/.test(text)) {
  data.firstName = text.split(" ")[0];
}

The Over-Engineering Disaster

Here's the embarrassing part. Early on, a collaborator and I went deep on making this thing "production-grade." We added memory management utilities, operation queuing, error boundaries, resource tracking, and a MutationObserver-based system to detect DOM changes in real-time.

The content script ballooned to over 4,000 lines. It had its own garbage collector. For a Chrome extension that copies text to a clipboard.

One commit in the git history tells the whole story:

d364f73 remove over-engineering
content.js | 4457 +++--------- (316 insertions, 4,141 deletions)

That's 4,141 lines deleted in a single commit. What replaced it? A simple polling approach with setTimeout that tries a selector a few times and gives up. It's less elegant on paper, but it works perfectly in practice because the elements we're looking for appear within milliseconds of a page transition.

The lesson: don't build infrastructure for problems you don't have. A setTimeout loop with 5 attempts at 100ms intervals handles 99% of LinkedIn's async rendering. You don't need a MutationObserver framework for that.

LinkedIn's Messaging ContentEditable

The connection note flow uses a normal <textarea>, which is straightforward — set .value, dispatch an input event, done. But LinkedIn's messaging system uses a contenteditable div, which is a different beast entirely.

Setting textContent on a contenteditable doesn't trigger LinkedIn's internal state management. You need to fire multiple events to convince the app that a user actually typed something:

// content.js — convincing LinkedIn's messaging app
messageBox.textContent = message;

["input", "change", "keyup", "keydown"].forEach((eventType) => {
  messageBox.dispatchEvent(
    new Event(eventType, { bubbles: true, cancelable: true })
  );
});

Even then, I had to manually set the cursor position to the end of the message using Range and Selection APIs. Without this, the cursor sits at the beginning and the next keypress overwrites everything.

Schema Migration Without a Database

As the extension evolved from v1 (one template) to v7 (four templates, personal info, quick-fill variables), the config shape changed repeatedly. Users who installed an older version would have a different config structure in chrome.storage.sync.

I added a simple schema migration that runs on every config load:

// content.js — lightweight schema migration
migrateSchemaIfNeeded() {
  if (!this.config || this.config.schemaVersion === 3) return;

  const defaults = this.getDefaultConfig();

  // Preserve personal info from old config
  if (this.config.personalInfo) {
    defaults.personalInfo = {
      ...defaults.personalInfo,
      ...this.config.personalInfo,
    };
  }

  this.config = defaults;
  chrome.storage.sync.set({ [this.storageKey]: this.config });
}

It's basically "start with new defaults, layer in whatever the user had before, bump the version." No migration framework, no version history table. Just a function that runs once and fixes the shape.

What I'd Do Differently

Start with copy-to-clipboard only. The direct message insertion (into the contenteditable) was the most fragile and time-consuming part. If I'd shipped with just clipboard copy from day one, I'd have had a working tool in an afternoon instead of a week.

Skip the popup config UI initially. I spent a lot of time on a nice tabbed popup with live preview, template editing, and storage indicators. For the first version, a simple options.html with a form would have been fine.

Use TypeScript. The codebase is vanilla JS with no build step, which was nice for simplicity but made refactoring painful. One content.ts file with interfaces for the config shape would have caught several bugs earlier.

Key Takeaways

  1. Selector fallback chains are the right pattern for scraping SPAs. Don't rely on a single CSS selector for anything. Try multiple, validate semantically, and fail gracefully.

  2. Over-engineering kills side projects. If you're building a tool for yourself, ship the simplest thing that works. You can always add complexity later — but you can't un-waste the time spent building a garbage collector for a clipboard utility.

  3. contenteditable is always harder than you think. If you're building any kind of browser extension that interacts with rich text editors, budget extra time. Every app handles it differently.

  4. Schema migration doesn't need a framework. For local-first apps with simple configs, a single migrateIfNeeded() function that merges old data into new defaults is all you need.

  5. LinkedIn's accessibility attributes are more stable than its CSS classes. When scraping LinkedIn, prefer aria-label, role, and data-test-* attributes over class names.


The extension is on GitHub if you want to try it or adapt it for your own outreach flow.