DEV Community

Cover image for How Pinterest's Image CDN Actually Works
Pin Vexa
Pin Vexa

Posted on

How Pinterest's Image CDN Actually Works

Right-click any pin, choose "Open image in new tab", and look at the URL. You'll get something like this:

https://i.pinimg.com/236x/3f/8a/c1/3f8ac1d29e4b7f0a6c5d2e1b9a8f7c6d.jpg
Enter fullscreen mode Exit fullscreen mode

That 236x is not decoration. It means you are looking at a copy resized to 236 pixels wide, the size the home grid needs. The original file the creator uploaded is still sitting on the same CDN, one folder away.

I went down this rabbit hole while building a media extractor, and the URL structure turned out to be one of the cleanest CDN designs I've worked with. Here's everything I learned.

Anatomy of a pinimg.com URL

Part Example What it means
Host i.pinimg.com Image CDN (videos live on v1.pinimg.com)
Size folder 236x Which resized variant you get
Shard folders 3f/8a/c1 First 6 characters of the hash, split in pairs
Filename 3f8ac1d2...jpg Content hash of the file + extension

The shard folders are a classic trick: splitting by hash prefix keeps any single directory from holding millions of files. The useful part for us is that the hash never changes between sizes. Only the size folder does.

The size folders

Folder Typical use
75x75_RS Square avatar-style crop
140x, 170x Tiny previews
236x Home feed grid
474x, 564x Larger grid / board views
736x Pin close-up page
1200x Large display
originals The uploaded file, unresized

The x with no height means "this width, keep the aspect ratio". So upgrading a thumbnail is a one-line regex:

const SIZE_RE = /\/(?:\d+x\d*(?:_RS)?|originals)\//;

function toSize(url, size = 'originals') {
  return url.replace(SIZE_RE, `/${size}/`);
}

toSize('https://i.pinimg.com/236x/3f/8a/c1/3f8ac1d2.jpg');
// -> https://i.pinimg.com/originals/3f/8a/c1/3f8ac1d2.jpg
Enter fullscreen mode Exit fullscreen mode

There's one catch: originals doesn't always exist in the extension you expect. A pin served as .jpg in the grid may have been uploaded as .png, and some originals return 403. So in practice you walk down a ladder until something answers:

const LADDER = ['originals', '1200x', '736x', '564x'];

async function bestAvailable(url) {
  for (const size of LADDER) {
    const candidate = toSize(url, size);
    const res = await fetch(candidate, { method: 'HEAD' });
    if (res.ok) return candidate;
  }
  return url; // fall back to what we were given
}
Enter fullscreen mode Exit fullscreen mode

Run this server-side. From the browser, CORS will get in your way.

How big is the difference, really?

Want to see it on your own pin? This script pulls one pin at every size and prints the resolution and file size:

import { imageSize } from 'image-size';

const base = 'https://i.pinimg.com/236x/3f/8a/c1/3f8ac1d2.jpg'; // your pin

for (const size of ['236x', '564x', '736x', 'originals']) {
  const res = await fetch(toSize(base, size));
  if (!res.ok) { console.log(size, res.status); continue; }
  const buf = Buffer.from(await res.arrayBuffer());
  const { width, height } = imageSize(buf);
  console.log(size, `${width}x${height}`, `${Math.round(buf.length / 1024)} KB`);
}
Enter fullscreen mode Exit fullscreen mode

The jump from 736x to originals is where it matters for designers. Zoom into a moodboard reference at 736x and fine texture turns to mush; the original holds up.

Videos play by different rules

Videos live on a separate host, v1.pinimg.com, and come in two flavours:

https://v1.pinimg.com/videos/mc/hls/ab/cd/ef/<hash>.m3u8     # adaptive stream
https://v1.pinimg.com/videos/mc/720p/ab/cd/ef/<hash>.mp4     # single file
Enter fullscreen mode Exit fullscreen mode

The HLS playlist is what the player uses; it switches quality on the fly and splits audio and video into segments. The MP4 is a single progressive file, which is what you actually want to save.

You can't upgrade videos with a regex the way you can with images. The available variants are listed in the pin's data as a video_list object, keyed by names like V_720P or V_HLSV4 (the exact keys change over time). Pick the largest MP4 from whatever is there:

function pickBestMp4(videoList) {
  return Object.values(videoList)
    .filter(v => v.url?.endsWith('.mp4'))
    .sort((a, b) => b.width * b.height - a.width * a.height)[0] ?? null;
}
Enter fullscreen mode Exit fullscreen mode

An honest note: you can only get what was uploaded. If a creator posted a 720p clip, no tool can turn it into 1080p. Anything claiming otherwise is upscaling, not extracting.

Where the media URLs actually live

To upgrade a URL you first need one. A pin page exposes its media in three places, and each fails differently:

Source What it gives you How it breaks
Hydration JSON (__PWS_DATA__ script) Everything: all image sizes, video_list, story pages, carousel slots Structure changes often; key paths move between deploys
JSON-LD (application/ld+json) Main image, sometimes contentUrl for video Missing on some pin types; usually one image only
OpenGraph (og:image, og:video) A mid-size image, sometimes a video URL Almost always present, rarely the best quality

The richest source is the most fragile, and the most stable source is the poorest. So you don't pick one; you chain them.

function extractMedia(html) {
  return fromHydration(html) ?? fromJsonLd(html) ?? fromOpenGraph(html);
}
Enter fullscreen mode Exit fullscreen mode

Tier 3, OpenGraph. The safety net. Grab the tag, then upgrade it:

function fromOpenGraph(html) {
  const m = html.match(/<meta[^>]+property="og:image"[^>]+content="([^"]+)"/);
  return m ? { type: 'image', url: toSize(m[1]) } : null;
}
Enter fullscreen mode Exit fullscreen mode

Tier 2, JSON-LD. Structured, but there can be several blocks and not all are about the pin:

function fromJsonLd(html) {
  const blocks = html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g);
  for (const [, raw] of blocks) {
    try {
      const data = JSON.parse(raw);
      if (data.contentUrl) return { type: 'video', url: data.contentUrl };
      const img = data.image?.url ?? data.image;
      if (typeof img === 'string') return { type: 'image', url: toSize(img) };
    } catch { /* skip malformed block */ }
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Tier 1, hydration JSON. Don't hard-code a path like props.initialReduxState.pins[id]; it will break on the next deploy. Search by shape instead. Images carry an images object with an orig entry, videos carry video_list:

function findFirst(node, predicate) {
  if (!node || typeof node !== 'object') return null;
  if (predicate(node)) return node;
  for (const value of Object.values(node)) {
    const hit = findFirst(value, predicate);
    if (hit) return hit;
  }
  return null;
}

function fromHydration(html) {
  const m = html.match(/<script[^>]+id="__PWS_DATA__"[^>]*>([\s\S]*?)<\/script>/);
  if (!m) return null;
  const data = JSON.parse(m[1]);
  const video = findFirst(data, n => n.video_list);
  if (video) return { type: 'video', url: pickBestMp4(video.video_list)?.url };
  const pin = findFirst(data, n => n.images?.orig?.url);
  return pin ? { type: 'image', url: pin.images.orig.url } : null;
}
Enter fullscreen mode Exit fullscreen mode

Searching by shape keeps working when Pinterest reshuffles its state tree; hard-coded paths are the first thing to break.

Edge cases that will bite you

  • Short links. pin.it/xxxx is a redirect. Resolve it first (fetch(url, { redirect: 'follow' }) and read res.url) before parsing anything.
  • Idea / Story pins. One pin, many pages. Each page is its own block with its own image or video, so return an array, not a single URL.
  • Carousel pins. Same idea: several slots, each with its own images object. Upgrade every slot separately.
  • GIFs that aren't GIFs. Many animated pins are delivered as short MP4 loops for performance. The real .gif may only exist under originals, so check both.
  • Extension mismatch. The grid shows .jpg; the original may be .png or .webp. If originals 404s, try the other extensions before stepping down the size ladder.
  • Private or removed pins. No hydration data, no JSON-LD, just a login wall. Fail fast with a clear message instead of retrying.

Building it for real

Once the pieces worked, I wrapped them into a small serverless resolver: resolve the link, fetch the page once, run the three-tier extractor, upgrade the URL, then cache the result by pin ID so repeat requests never touch Pinterest again.

I turned this logic into a small free tool, Pinvexa, if you want to see the original-size upgrade in action. Paste any pin link and compare what you get against the 236x thumbnail.

Respect the platform

Just because a file is reachable doesn't mean it's yours. A few rules I stick to:

  • Only work with public pins. If something sits behind a login, leave it there.
  • Saving a reference for your own moodboard is one thing; reposting someone's work as your own is another. Credit the creator.
  • Cache aggressively and rate-limit yourself. One page fetch per pin is plenty.
  • Read Pinterest's terms and honour takedown requests if you run anything public.

What I took away from this

Pinterest's CDN is a nice lesson in boring, predictable design: a content hash, sharded folders and a size prefix. Once you see the pattern, a whole class of "why is this image so blurry?" problems disappears.

Have you reverse-engineered another platform's CDN? Instagram, Behance, Dribbble? I'd love to compare notes in the comments.


I'm Prince, an SEO consultant who also builds small web tools. I write about the engineering behind them. My latest is Pinvexa, a Pinterest media toolkit.

Top comments (0)