LinkOff - Making LinkedIn Slightly Less Dreadful
A deep dive into the engineering of a silly little browser extension that I made as a form of trauma response after spending weeks writing and testing LinkedIn automation code at work.
“The only teaching that a professor can give, in my opinion, is that of thinking in front of his students.” - Henri Lebesgue
Back during my time at Lebesgue (a boutique marketing insights and analytics company), I was doing a lot of interesting cross-functional work, as it was called back then. We had our internal sales pipeline, and it had quite a bit of cold outreach. However, cold-mailing is a pain - from spam filters to bad email lists, it’s generally frowned upon. The newer kid on the block, LinkedIn, seemed like a potentially more interesting option. Comparing to email’s 5%, LinkedIn often had response rates of 15-20%.
On top of that, LinkedIn was way more compatible with the new, more personal, founder-focused marketing approach. Instead of presenting as a firm corporate company front, you lead and present the CEO/CTO first and foremost, and make sales based on that.
Because of that, I found myself spending hours every day working on LinkedIn automations. Hours and hours of writing scrapers, dealing with anti-bot measures, handling rate limits, and debugging why the “Connect” button selector changed and broke everything.
But that also meant I spent hours on LinkedIn… Watching the Feed. Ugh. So I built LinkOff. Let this be a tech dive into it.
What is LinkOff?
LinkOff is a browser extension that transforms LinkedIn from a chaotic social media feed into a focused professional tool. It can hide entire categories of content (polls, videos, promoted posts), filter by keywords, block posts from companies or people, and even mass-delete messages.
Architecture Overview
LinkOff uses a classic content script architecture that’s common in browser extensions:
flowchart
A["Popup UI<br/>(popup.html)"]
B["Service Worker<br/>(service_worker.js)"]
C["Content Script<br/>(injected JS)"]
D["Chrome Storage<br/>(local)"]
subgraph topRow[" "]
direction LR
A <--> B
B <--> C
A ~~~ C
end
B ---> D
style topRow fill:none,stroke:none,color:transparent
Key Components
- Popup UI (popup/popup.html) - The extension’s settings interface
- Service Worker (service_worker.js) - Background script that manages storage defaults
- Content Script (content/content.js) - Injected into LinkedIn pages to modify content
- Feature Modules (features/) - Modular filtering logic for different LinkedIn sections
The Content Injection Strategy
The most interesting architectural choice is how LinkOff injects its code. Instead of putting everything in the content script directly, it dynamically imports ES6 modules:
1
2
3
4
5
6
7
8
// src/content/content.js
'use strict'
const src = chrome.runtime.getURL('src/index.js')
// We dynamically import files to be able to use ES6 modules
// Remember to add imported files to web_accessible_resources
import(src)
This approach has several benefits:
- ES6 modules work properly (content scripts have limited module support)
- Code splitting becomes possible
- Dynamic loading allows for conditional feature loading
- Better debugging with proper source maps
The trade-off is that all imported files must be declared in web_accessible_resources in the manifest.
The Filtering Engine
DOM Element Selection Strategy
LinkedIn’s DOM structure is notoriously unstable - class names change frequently and the site is heavily React-based. LinkOff solves this with a multi-layered selection strategy:
1
2
3
4
5
6
7
8
9
10
11
// src/constants.js
export const FEED_SELECTORS = [
'[data-id*="urn:li:activity"]', // LinkedIn's internal activity URNs
'[data-id*="urn:li:aggregate"]', // Aggregated content URNs
]
export const JOB_SELECTORS = [
'[data-job-id]',
'[data-occludable-job-id]',
'.discovery-templates-vertical-list__list-item',
]
The key insight is using data attributes rather than CSS classes. LinkedIn’s data-id attributes containing URNs (Uniform Resource Names) are much more stable than their styling classes.
Asynchronous Element Waiting
Since LinkedIn loads content dynamically, LinkOff implements sophisticated waiting logic:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/utils.js
export const waitForClassName = async (className) => {
while (checkElementAndPlaceholderByClassName(className)) {
await new Promise((resolve) => {
requestAnimationFrame(resolve)
})
}
return document.getElementsByClassName(className)
}
const checkElementAndPlaceholderByClassName = (className) => {
const found = document.getElementsByClassName(className)
if (found.length > 0) {
return Array.from(found).some((element) =>
element.innerHTML.includes('skeleton') // LinkedIn's loading placeholder
)
}
return true
}
This pattern waits for:
- Elements to exist in the DOM
- LinkedIn’s skeleton loaders to be replaced with real content
- React hydration to complete
Using requestAnimationFrame ensures the waiting loop doesn’t block the UI thread.
The Three-Mode Filtering System
LinkOff implements three different ways to handle unwanted content:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/content/content.css
.hide[class] {
display: none !important;
}
.dim:not(:hover) > * {
opacity: 0.05 !important;
filter: alpha(opacity=5) !important;
}
.dim.showIcon:not(:hover)::after {
content: '';
background-image: var(--hide-icon);
background-size: cover;
width: 30px;
height: 30px;
}
- Hide mode: Complete removal (
display: none) - Dim mode: Fade to 5% opacity with click-to-reveal (so funky right?)
- Icon overlay: Visual indicator showing content was filtered
The “gentle mode” (dim) is particularly clever - it lets users verify the filtering is working correctly without permanently losing content.
Real-Time Keyword Filtering
The feed filtering system is the most complex part of LinkOff:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/features/feed.js
const blockByFeedKeywords = (keywords, mode, disablePostCount) => {
if (keywords.length)
feedKeywordInterval = setInterval(() => {
// Select posts which are not already hidden
posts = document.querySelectorAll(
getCustomSelectors(FEED_SELECTORS, 'pristine')
)
// Filter only if there are enough posts to load more
if (posts.length > 5 || mode == 'dim') {
posts.forEach((post) => {
const containsKeyword = keywords.some((keyword) => {
const splitted = keyword.split('::')
if (splitted.length > 1) {
return post.innerText.indexOf(splitted[1]) !== -1 // text content
}
return post.innerHTML.indexOf(splitted[0]) !== -1 // HTML content
})
if (containsKeyword) {
hidePost(post, mode)
}
})
}
}, 350)
}
Key engineering decisions:
- Polling every 350ms - Balance between responsiveness and performance
posts.length > 5check - LinkedIn’s infinite scroll needs a minimum number of posts to trigger loading more- Text vs HTML filtering -
text::prefix filters visible text, otherwise filters HTML - State tracking - Posts are marked with
data-hiddenattributes to avoid reprocessing
Post Age Based Filtering
Who loves standardized datte time formats? I don’t. LinkedIn uses a mix of relative times (“2h”, “3d”) and absolute dates (“Sep 15”, “Jan 5, 2020”). LinkOff implements a cascading keyword system to handle this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/features/feed.js
const handleAgeFiltering = (keywords, age) => {
const ageKeywords = {
hour: 'h •',
day: 'd •',
week: 'w •',
month: 'mo •',
year: 'y •',
}
const hideByHour = (shouldLoop = true) => {
if (shouldLoop) {
for (let x = 2; x <= 24; x++) {
keywords.push(`text::${x}${ageKeywords.hour}`) // "2h •", "3h •", etc.
}
}
hideByDay(false) // Cascade to hide older content too
}
}
This cascading approach means “hide posts older than 1 day” will also hide posts older than 1 week, 1 month.
Storage and State Management (Settings)
LinkOff uses Chrome’s local storage API with a sophisticated default system:
1
2
3
4
5
6
7
8
9
10
11
12
13
// src/service_worker.js
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
chrome.storage.local.set({
initialized: 'v0.5',
'gentle-mode': true,
'hide-promoted': true,
'hide-suggested': true,
'sort-by-recent': true,
// ... dozens more defaults
})
}
})
The main content script listens for storage changes and reacts in real-time:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/index.js
chrome.storage.onChanged.addListener(() => {
getStorageAndDoIt()
})
const doIt = async (response) => {
if (JSON.stringify(oldResponse) === JSON.stringify(response)) return
const getRes = (field, bool) => {
const changed = response[field] !== oldResponse[field] ||
response['gentle-mode'] !== oldResponse['gentle-mode'] ||
response['main-toggle'] !== oldResponse['main-toggle']
return changed && response[field] == bool
}
// Only update features that actually changed
doFeed(getRes, enabled, mode, response)
doJobs(getRes, enabled, mode, response)
doMisc(getRes, enabled, mode)
}
This change detection system ensures only modified features are updated, preventing unnecessary DOM manipulation.
The Popup UI: Tagify Integration
The keyword input system uses Tagify, a sophisticated tag input library:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/popup/popup.js
const feedKeywords = document.querySelector('input[id=hide-by-keywords]')
const feedTagify = new Tagify(feedKeywords, {
whitelist: [
'Be the first to comment',
'Jobs recommended for you',
'New post in',
],
dropdown: {
position: 'input',
enabled: 0,
placeAbove: true,
},
originalInputValueFormat: (valuesArr) =>
valuesArr.map((item) => item.value).join(', '),
})
Tagify provides:
- Autocomplete with common filtering keywords
- Tag validation and formatting
- Comma-separated value storage for the backend
- Professional UI that matches the extension’s design
Cross-Platform Compatibility
Supporting both Chrome and Firefox required handling browser differences:
1
2
3
4
5
6
7
8
9
10
11
12
/* src/content/content.css */
@supports (-webkit-user-select: none) {
:root {
--hide-icon: url('chrome-extension://__MSG_@@extension_id__/src/icons/forbidden.svg');
}
}
@supports (-moz-user-select: none) {
:root {
--hide-icon: url('moz-extension://__MSG_@@extension_id__/src/icons/forbidden.svg');
}
}
The @supports CSS feature detection automatically selects the correct extension protocol (chrome-extension:// vs moz-extension://).
Dark Mode Implementation (happily deprecated since 2023)
LinkOff includes a custom dark mode that pre-dates LinkedIn’s official dark mode:
1
2
3
4
5
6
7
8
9
10
11
// src/features/general.js
const enableDarkMode = () => {
const style = document.createElement('style')
style.innerHTML = colors
.replace(/100%|0%/g, (m) => m == '100%' ? '0%' : '100%')
.replace(/#000|#fff/g, (m) => m == '#fff' ? '#000' : '#fff')
.replace(/#([fe])(.)([fe])(.)([fe])(.)/g, `#1$21$41$6`)
.replace(/\d+(?=,)/g, (m) => shift255(m))
document.body.appendChild(style)
}
This is a CSS filter inversion approach - it programmatically inverts colors in LinkedIn’s existing stylesheets rather than rewriting them. The regex patterns handle:
- Percentage values (100% ↔ 0%)
- Hex colors (#000 ↔ #fff)
- Bright hex colors (shifts RGB values)
- RGB numeric values (adds 220-255 then modulo 256)
This way it s a bit more dynamic.
Message Deletion Feature
One of LinkOff’s most popular features is bulk message deletion:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/features/message.js
const selectMessagesForDeletion = async () => {
const container = document.querySelector('.msg-conversations-container__conversations-list')
await loadAllMessages() // Scroll to load all conversations
const labels = container.getElementsByTagName('label')
for (let i = 0; i < labels.length; i++) {
if (labels[i]) {
labels[i].click() // Select each conversation checkbox
}
}
alert('Click the trash can icon at the top to delete all messages.')
}
async function loadAllMessages() {
return await new Promise((resolve) => {
const interval = setInterval(() => {
const { scrollHeight } = container
if (scrollHeight > 20000) { // Reasonable limit
clearInterval(interval)
resolve()
}
container.scrollTop = scrollHeight // Scroll to bottom
}, 1000)
})
}
This feature:
- Auto-scrolls to load all message conversations
- Programmatically clicks all selection checkboxes
- Stops at 20k pixels to prevent infinite scrolling
- Prompts the user to click the delete button (avoiding destructive automation)
It is also sort of cool, because it was one of the things that were a modification of the LinkedIn UI.
Build System and Tooling
LinkOff keeps its build system minimal:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// package.json
{
"scripts": {
"css-build": "sass src/popup/popup.scss src/popup/popup.css",
"css-watch": "sass --watch src/popup/popup.scss src/popup/popup.css"
},
"dependencies": {
"@creativebulma/bulma-divider": "^1.1.0",
"@creativebulma/bulma-tooltip": "^3.0.2",
"@yaireo/tagify": "^4.17.9",
"bulma": "^0.9.4",
"bulma-switch": "^2.0.0"
}
}
The choice to use Sass/SCSS instead of a full build system like Webpack keeps things simple while providing:
- CSS preprocessing with variables and mixins
- Bulma CSS framework for consistent styling
- Component libraries for switches and tooltips
- Zero JavaScript build step - just copy files to the extension directory
Performance Considerations
Several engineering decisions optimize for performance:
Minimal DOM Queries
1
2
// Cache selectors to avoid repeated queries
let posts = document.querySelectorAll(getCustomSelectors(FEED_SELECTORS, 'pristine'))
Efficient CSS Selectors
1
2
3
// Use attribute selectors instead of class crawling
'[data-id*="urn:li:activity"]' // Fast
'.feed-post .content .wrapper' // Slower
Batched Updates
1
2
3
4
// Update all matching elements in one pass
posts.forEach((post) => {
post.classList.add(mode, 'showIcon')
})
Polling Frequency Balance
1
2
// 350ms polling - balance between responsiveness and CPU usage
setInterval(() => { /* filter logic */ }, 350)
Security and Permissions
LinkOff requests minimal permissions:
1
2
3
4
5
6
7
8
9
10
// manifest.json
{
"permissions": [
"storage",
"activeTab"
],
"host_permissions": [
"https://www.linkedin.com/*"
]
}
storage- For saving user preferencesactiveTab- Only access the currently active tab when extension is used- LinkedIn-only host permissions - Can’t access other websites
This is much more restrictive than many extensions that request <all_urls> or persistent background access.
Testing Strategy
While LinkOff doesn’t have formal unit tests, it implements several testing patterns:
Console Logging
1
2
console.log(`LinkOff: Found ${posts.length} unblocked posts`)
console.log(`LinkOff: Blocked post ${post.getAttribute('data-id')} for keyword ${keyword}`)
Graceful Error Handling
1
2
3
4
if (!container) {
alert('No messages. Are you on the messaging page?')
return
}
User Feedback
1
2
3
if (!postCountPrompted && !disablePostCount) {
alert('Scroll down to start blocking posts (LinkedIn needs at least 10 loaded)')
}
These patterns make debugging easier and provide user feedback when things go wrong.
Lessons Learned
DOM Stability
Problem: LinkedIn’s class names change frequently
Solution: Use data attributes and URN patterns instead
React Compatibility
Problem: React can re-render and remove custom classes
Solution: Use polling and re-apply modifications continuously
Performance vs Accuracy
Problem: Faster polling catches content sooner but uses more CPU
Solution: 350ms polling with batch processing
User Experience
Problem: Users need to understand what’s being filtered
Solution: “Gentle mode” with visual indicators and click-to-reveal
Contributing to LinkOff
The project is currently looking for a new maintainer. The codebase is well-organized and documented, making it a great starting point for anyone interested in browser extension development.
Key areas for contribution:
- Mobile support (Kiwi Browser, Firefox Mobile)
- Performance optimization
- New filtering features
- UI/UX improvements
- Cross-browser compatibility
The complete source code is available on GitHub under an MIT license.