I Built a YouTube Thumbnail Downloader With Zero Backend — Here's the Trick
A while back I needed a YouTube video's cover image for a presentation. I hit the usual wall: sketchy "downloader" sites full of redirects, browser extensions asking for permissions they had no business asking for, and the official Data API demanding a key and a quota just to fetch one image.
So I built CoverGrabber — a small tool that does the lookup for you. No signup, no API key, no server at all, actually. The whole thing runs client-side. Here's how.
The trick: YouTube's thumbnail URLs are predictable
Every YouTube video already has its thumbnails sitting on a public CDN at a fixed URL pattern:
https://img.youtube.com/vi/{VIDEO_ID}/maxresdefault.jpg
https://img.youtube.com/vi/{VIDEO_ID}/sddefault.jpg
https://img.youtube.com/vi/{VIDEO_ID}/hqdefault.jpg
https://img.youtube.com/vi/{VIDEO_ID}/mqdefault.jpg
https://img.youtube.com/vi/{VIDEO_ID}/default.jpg
No auth, no API, no quota. If you have the 11-character video ID, you have every thumbnail size that exists for it. The "hard" part isn't fetching the image, it's figuring out the ID and knowing which of those five sizes actually exist.
Step 1: pulling the ID out of any URL format
People paste links in every shape imaginable: watch?v=, youtu.be/, /shorts/, /embed/. One regex handles all of them:
function extractId(url) {
const m = url.match(/(?:v=|\/shorts\/|\/embed\/|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
return m ? m[1] : null;
}
Video IDs are always exactly 11 characters of [A-Za-z0-9_-], so that's the one invariant I could anchor on regardless of which URL shape shows up.
Step 2: the part that actually tripped me up
Not every video has a maxresdefault.jpg. Smaller or older uploads sometimes only go up to sddefault or hqdefault. My first instinct was to just try loading the image and catch a 404 with onerror.
That doesn't work. YouTube never 404s on these URLs. If a size doesn't exist, it silently returns a 120×90 grey placeholder image instead of an error. Which means onerror never fires, and you end up rendering a tiny grey box as if it were a real thumbnail.
The fix: load it anyway, then check the actual dimensions of what came back.
function checkImage(src) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
// YouTube serves a 120x90 grey placeholder when a size
// doesn't exist for this video — that's the tell.
resolve(!(img.naturalWidth === 120 && img.naturalHeight === 90));
};
img.onerror = () => resolve(false);
img.src = src;
});
}
If naturalWidth/naturalHeight come back as exactly 120×90, it's the placeholder, not a real thumbnail, so I just skip that size in the results grid. This one check is basically the whole reason the tool feels reliable instead of showing broken/empty boxes half the time.
Step 3: checking all five sizes in parallel
const SIZES = ['maxresdefault', 'sddefault', 'hqdefault', 'mqdefault', 'default'];
const results = await Promise.all(
SIZES.map(async (size) => {
const src = `https://img.youtube.com/vi/${id}/${size}.jpg`;
const ok = await checkImage(src);
return { size, src, ok };
})
);
results.filter(r => r.ok).forEach(renderThumbnail);
Five image loads, checked concurrently, filtered down to whatever's real. That's the entire backend. There isn't one.
Downloading without a server
The last piece was making "Download" actually download instead of just opening the image in a new tab. Since the images are cross-origin, I fetch them as a blob and trigger the save through an object URL, falling back to window.open if a browser blocks it:
async function downloadImage(url, filename) {
try {
const res = await fetch(url, { mode: 'cors' });
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(blobUrl);
} catch {
window.open(url, '_blank'); // graceful fallback
}
}
What it turned into
That core trick ended up being reusable enough that I built a couple more tools around it, a thumbnail resizer and a banner resizer, both using the Canvas API for the drag-to-crop part instead of pulling in a library. Same philosophy throughout: no backend, nothing uploaded anywhere, everything happens in the tab you're already in.
You can try the original tool here: covergrabber.com
Feedback welcome
This is a side project I'm actively iterating on. If you've dealt with the same "grab this public image without spinning up a backend" problem for something else, I'd genuinely like to hear how you approached it. And if you spot an edge case where the 120×90 check would misfire, drop it in the comments, that's exactly the kind of thing I want to know about.
Top comments (0)