How I Built a Chrome Image Downloader Extension

This is the story of a small annoyance about right-clicking to save images, and of building a Chrome extension with a friend from the first line of code to the point where it was ready to ship. The most interesting part technically is everything that looked simple and turned out not to be once we actually started.

It all started with a tiny annoyance

Have you ever been browsing Instagram or Pinterest, seen a beautiful image you wanted to keep, and found that saving it means right click, pick “Save image as”, wait for the dialog, choose a path… the whole thing tedious enough that you lose patience partway through?

That was exactly my problem last year while collecting design references. As a developer, my first reaction was: “there has to be a better way.”

I was gathering material for a design project and needed to download a lot of images from all sorts of sites. The right click routine got irritating fast, especially once I noticed the images I saved often weren’t the highest resolution version available.

“Why can’t it work like a phone app, where you long press and the image is saved?” I thought.

From an idea to the first line of code

One weekend afternoon I decided to fix it. The initial idea was simple: build a Chrome extension that lets you save an image quickly by hovering and clicking.

I shared the idea with my friend Felix Falkenberg (@ffalkenberg), an ML engineer and DevOps specialist. He lives as a digital nomad and has a sharp nose for efficiency tools, and he saw the potential immediately.

“That’s a real pain point,” Felix said. “We could build this together. Neither of us has ever shipped a Chrome extension to the store, so it’s a decent exercise.”

I’d written a few Chrome extensions before, but all of them stopped at the practice level. I’d use one myself for two days, then put it on ice, and none of them ever reached the store. What was different this time is that from the start we meant to build something other people would use.

Once we started digging in, we found that this “simple” idea had quite a bit of trouble hiding behind it.

The first challenge: detecting hover

Hovering over an image on Instagram, a one-click download button appears in the bottom right corner

Here’s what the finished product does: hover over any image and a download button appears in the bottom right corner, one click and the file is saved. Getting that button to appear reliably was far more trouble than it sounds.

1
2
3
4
5
6
// First attempt: the naive approach
document.addEventListener('mouseover', function(e) {
if (e.target.tagName === 'IMG') {
showDownloadButton(e.target);
}
});

Looks simple, right? Actual testing produced one problem after another:

  • The button flickered constantly
  • Performance problems made pages stutter
  • Some images were never detected
  • The button showed up in the wrong place

It made me realize that doing a seemingly simple feature well involves far more detail than you’d expect.

Down the rabbit hole: event optimization

After a few days of reading and testing, I learned why event delegation and debouncing matter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Optimized version
let hoverTimeout;
let currentImage = null;

document.addEventListener('mouseover', function(e) {
if (e.target.tagName === 'IMG' && e.target !== currentImage) {
clearTimeout(hoverTimeout);
hoverTimeout = setTimeout(() => {
showDownloadButton(e.target);
currentImage = e.target;
}, 200); // 200ms delay to stop the flicker
}
});

document.addEventListener('mouseout', function(e) {
if (e.target.tagName === 'IMG') {
clearTimeout(hoverTimeout);
setTimeout(() => {
hideDownloadButton();
}, 300); // give the user time to move onto the button
}
});

The biggest challenge: finding the highest resolution image

Once the basics worked, we ran into the real technical challenge: many sites display a thumbnail, while what the user wants is the original size. That apparently simple requirement turned out to be the most complex part of the whole project.

The Google Images puzzle

Hovering over a Google Images search result, the ClickSave download button appears there too

Google Images was the hardest case we hit. The src attribute on the image element you see points only at a low resolution preview, while the real high resolution URL is buried deep in a complicated DOM structure and a set of data attributes.

I spent a full week studying the page structure of Google Images, chasing every possible lead like a detective:

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
29
30
31
32
// Google Images high-res detection: the final version after endless trial and error
function getGoogleImagesHighRes(img) {
// Approach 1: check the various possible data attributes
const dataUrl = img.getAttribute('data-src') ||
img.getAttribute('data-original-src') ||
img.getAttribute('data-iurl');

// Approach 2: read the parent element's data-ri attribute (Google's metadata)
const parent = img.closest('[data-ri]');
if (parent) {
try {
const metadata = JSON.parse(parent.getAttribute('data-ri'));
// 'ou' is short for original URL
if (metadata.ou) return metadata.ou;
} catch (e) {
// JSON parse failed, fall through to the other approaches
}
}

// Approach 3: parse srcset for the highest resolution
if (img.srcset) {
const srcsetUrls = parseSrcset(img.srcset);
return srcsetUrls[srcsetUrls.length - 1].url;
}

// Approach 4: try to recover the original from the URL parameters
if (img.src.includes('googleusercontent.com')) {
return img.src.replace(/=s\d+/, ''); // strip the size constraint parameter
}

return img.src; // last resort fallback
}

Every site is a new puzzle

Then we found that every major site has its own logic for storing images:

  • Instagram: the high resolution image hides in data-* attributes, but the attribute names change
  • Twitter: you modify a URL parameter to get the large version (:large vs :small)
  • Pinterest: the original URL is hidden inside a complicated JSON structure
  • Facebook: image versions nested several layers deep, requiring a recursive search

Each site needed its own resolver:

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
29
30
31
32
33
34
35
36
37
class ImageResolver {
constructor() {
this.siteHandlers = {
'instagram.com': this.handleInstagram,
'twitter.com': this.handleTwitter,
'x.com': this.handleTwitter, // Twitter's domain after the rename
'images.google.com': this.handleGoogleImages,
'pinterest.com': this.handlePinterest,
'facebook.com': this.handleFacebook
};
}

async resolveHighResUrl(img) {
const hostname = window.location.hostname;
const handler = this.siteHandlers[hostname];

if (handler) {
try {
const result = await handler(img);
if (result && result !== img.src) return result;
} catch (error) {
console.warn(`Handler failed for ${hostname}:`, error);
}
}

// Generic fallback: try the common high-res patterns
return this.genericResolve(img);
}

handleTwitter(img) {
if (img.src.includes('pbs.twimg.com')) {
// swap :small or any other size for :large
return img.src.replace(/:(small|medium|thumb)/, ':large');
}
return img.src;
}
}

Unexpected user experience challenges

With the technical problems solved, we started running into user experience problems we hadn’t anticipated.

Positioning a draggable button

At first we pinned the download button to the top right corner of the image, and problems showed up quickly:

  • On small images the button was overbearing
  • It sometimes covered something that mattered, a watermark or text
  • CSS from different sites interfered with how it rendered

We decided to make the button draggable, which brought a new technical problem: how do you keep the button inside the bounds of the image at all times?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Drag logic that keeps the button inside the image bounds
function constrainButtonPosition(button, image, newX, newY) {
const imgRect = image.getBoundingClientRect();
const btnRect = button.getBoundingClientRect();

// compute the valid drag range
const minX = 0;
const minY = 0;
const maxX = imgRect.width - btnRect.width;
const maxY = imgRect.height - btnRect.height;

// clamp to the bounds
const constrainedX = Math.max(minX, Math.min(maxX, newX));
const constrainedY = Math.max(minY, Math.min(maxY, newY));

return { x: constrainedX, y: constrainedY };
}

Filtering out decorative images

During testing we hit something we hadn’t expected: a lot of sites are full of small decorative images, such as:

  • 1x1 pixel tracking images
  • repeating patterns used for background decoration
  • loading placeholders
  • CSS background images

A download button on those is useless and gets in the way.

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
29
30
31
32
// Image filtering
function shouldShowDownloadButton(img) {
// drop images too small to be real content
if (img.width < 50 || img.height < 50) return false;

// drop the obvious tracking pixels
if (img.width === 1 && img.height === 1) return false;

// drop common placeholders and icons
const src = img.src.toLowerCase();
const suspiciousPatterns = [
'placeholder', 'loading', 'spinner', 'icon-',
'logo-small', 'avatar-default', '1x1', 'pixel'
];

if (suspiciousPatterns.some(pattern => src.includes(pattern))) {
return false;
}

// check whether it is a CSS background image
const computedStyle = window.getComputedStyle(img);
if (computedStyle.display === 'none' ||
computedStyle.visibility === 'hidden') {
return false;
}

// odd aspect ratios are usually dividers and other decoration
const aspectRatio = img.width / img.height;
if (aspectRatio > 20 || aspectRatio < 0.05) return false;

return true;
}

Visual feedback matters

I added careful animation and state indicators:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.download-button {
opacity: 0;
transform: scale(0.8);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

.download-button.show {
opacity: 1;
transform: scale(1);
}

.download-button:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}

Those small details are what made the whole thing feel smooth and pleasant.

A friend’s feedback opened a second direction

When we shared the first version with friends, the feedback made us rethink what the product was.

“This is great! But I often need to download every image on a page. Could you add batch downloading?”

That suggestion opened things up. Felix and I realized this wasn’t just a single image downloader but a complete image handling tool.

“Batch downloading means real concurrency control and error handling,” Felix warned me. “We need to design the architecture for this carefully.”

The technical challenge of batch downloading

ClickSave's side panel: every image on the page laid out in a grid, tick the ones you want and download them in one go

Batch downloading looks simple to implement and actually involves messy async work and error handling. We had to think about:

  • not firing too many download requests at once, since the browser will throttle you
  • handling the case where some downloads fail
  • giving the user progress feedback
  • avoiding duplicate downloads
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
class BatchDownloader {
async downloadAll(images) {
const results = [];
const concurrency = 3; // cap the concurrency

for (let i = 0; i < images.length; i += concurrency) {
const batch = images.slice(i, i + concurrency);
const promises = batch.map(img => this.downloadSingle(img));

try {
const batchResults = await Promise.allSettled(promises);
results.push(...batchResults);

// update progress
this.updateProgress(i + batch.length, images.length);

// keep the request rate down
await this.delay(500);
} catch (error) {
console.error('Batch download error:', error);
}
}

return results;
}
}

Technical debt and refactoring

As features piled up the code got complicated and hard to maintain, so we refactored:

1
2
3
4
5
6
7
8
9
// Before: one giant file, 500+ lines
// After: a modular structure
const ClickSave = {
core: new CoreModule(),
ui: new UIModule(),
downloader: new DownloadModule(),
settings: new SettingsModule(),
analytics: new AnalyticsModule()
};

The motive was practical. Every new site we supported meant another handler, and if each one required finding a spot inside a 500 line file, the project wouldn’t survive to the tenth site. Once the modules were split apart, the cost of adding a site dropped from “understand the whole file” to “implement one interface”.

Looking back, what was actually hard

Writing a tool like this, the hard part isn’t any single technical point. Every site is working against you. The high resolution URL on Google Images hides in data attributes that change, Twitter distinguishes sizes by URL parameter, Pinterest buries it deep in JSON, and all of those structures can shift at any time. What you’re writing is not a feature, it’s a long running arms race against the frontend teams of every major site. That’s also why there are so many tools like this on the market and so few good ones: this kind of code rots, because the other side keeps moving.

The other thing I took away is the value of splitting the work. Felix asked questions from the ML and DevOps side, I answered from the architecture side, and a lot of the design got better through picking holes in each other. The concurrency control in batch downloading exists only because he questioned first whether the browser would simply cut you off. Both of us were shipping an extension to a store for the first time, but that complementarity talked most of the wrong turns out of existence before we wrote any code.

What’s next

The store review is done and ClickSave is live on the Chrome Web Store. A few directions we want to go: Felix is looking at AI image processing, upscaling and background removal and that kind of thing, cross browser support for Firefox and Safari, and syncing settings across devices. Which of them actually happen depends on real usage data. Ranking them now would only be guessing.

One last thing

There are several extensions sitting on my computer that I finished and then put on ice. I learned something from each one, and then nothing came after. Halfway through this one I think I understood the difference: a practice project only has to get to “I understand it”, a product has to get to “nobody else has to understand it”. The first is the happy path plus a bit of curiosity. The second is 1x1 tracking pixels, data attributes that change, twenty kinds of decorative image with strange aspect ratios, and a user who will drag that button in a way you never saw coming.

You can’t feel the distance between those two until you actually walk the last step to the store.

If you have a few projects like that on your own drive, pick one and push it over that line. It’s a completely different sport from writing a new one.


Project links: