System design interview

Design a link preview service

Safe fetching, SSRF guards, tag parsing, an image proxy and a URL-keyed cache.

Overview
ClientPreview APIPreviewcacheFetch workerImage proxyEgress proxyImage storeRemote sitesClientPreview APIclean, coalescePreview cachekeyed by URLFetch workercaps, parse tagsImage proxyresize, encodeEgress proxyblocks bad IPsImage storeresized copiesRemote sites

Press Play to follow a request through the design, or step through it.

  • Client and edge
  • Service
  • Data store
  • Cache and queue
  1. A user pastes a link. The client asks the API for its card.
  2. The API cleans the URL into its standard form, then looks it up in the cache.
  3. The card is there and still fresh, so the cache returns it.
  4. The client shows the card. No page was fetched.
  • Client
  • Preview API: clean, coalesce
  • Preview cache: keyed by URL
  • Fetch worker: caps, parse tags
  • Image proxy: resize, encode
  • Egress proxy: blocks bad IPs
  • Image store: resized copies
  • Remote sites
On this page

In 2 minutes

I would build the service around two ideas: safe fetching and one fetch per link. A client asks a preview API, which cleans the URL into a standard form and looks it up in a cache. At 20 million requests a day, about 80% hit, so we need only about 46 fetches a second, and about 460 at the peak. On a miss, only one worker fetches a given URL. Everyone else waits for that result, so a link shared by 100,000 people causes one fetch.

The worker never connects on its own. It goes through an egress proxy in an isolated network that resolves the name once, refuses private, loopback and link-local addresses, checks every redirect, and caps the size at 2 MB and the time at 5 seconds. The worker reads the head of the page and prefers Open Graph tags, then Twitter tags, then the title tag. It treats every value as plain text. Images are never hotlinked.

An image proxy fetches the original, caps its pixels, strips metadata, re-encodes it at up to 1,200 pixels wide and stores it for the CDN. Cards live 24 hours with conditional refresh, and failures are cached for a few minutes. The trade-off is a card that can be a day old, in return for safety and almost no load on other sites.

Hide the answers and test yourself

Step 1 of 6: Clarify the scope

Interviewer asks

Design a link preview service. When someone pastes a URL into a chat or a post, the app shows a card with the page's title, a short description and an image. The service must fetch pages it does not control, so it has to be safe against hostile links, and cheap for links that many people share.

Questions to ask first

Ask these before you draw anything. Under each one is why its answer changes the design.

Who calls the service: only our own apps, or outside developers too?

Our own apps can be trusted with options. Outside callers need keys, quotas and stricter abuse limits.

Is the card built once when a message is sent, or again for every viewer?

Building it once and storing it means a million viewers cause no fetches. Building per viewer makes the cache the whole design.

How fresh must a card be, and what if the page changes?

A day-old title is fine for most links. The answer sets the cache lifetime and decides if we need a refresh button.

Which content do we support: HTML pages only, or also images, video, PDFs and embeds?

HTML with standard tags is a small problem. Embeds and rendering pages in a browser are each a project of their own.

What should the user see if the page cannot be fetched?

A plain link is a fine answer. Knowing this lets the service fail soft instead of blocking the message.

Requirements

Functional

  • Given a URL, return a title, a description, a site name and an image, or a plain link when nothing can be read.
  • Read Open Graph and Twitter card tags first, then fall back to the page title and the first large image.
  • Serve every preview image from our own domain, resized, and never from the page's host.
  • Let a user or support agent refresh a card or remove it.

Non-functional

  • A cached card: p99 under 100 ms. A new link: p95 under 3 seconds.
  • Never connect to a private, loopback or link-local address, including through a redirect.
  • A fetch reads at most 2 MB, follows at most 5 redirects and gives up after 5 seconds.
  • A link shared by 100,000 people in one minute causes one fetch.
  • 99.9% availability, and one slow site never slows other previews.

Step 2 of 6: Size it

Most requests are cache reads, and the fetch load is modest: a few hundred at the peak. The work is in making each fetch safe, and in never fetching the same link twice at once.

Assumed for the exercise, not facts about a real system:

  • 20 million preview requests a day. Peak traffic is 10 times the average.
  • 80% of requests find the card in the cache, so 20% need a fetch. A fetch takes about 0.8 seconds on average.
  • We stop reading a page after its head section, so an average fetch downloads about 100 KB. A stored card is about 1 KB.
  • 60% of cards have an image. We store one resized copy of about 80 KB for each.
Requests per second
20M ÷ 86,400 s ≈ 231
≈ 230/s, 2,300/s at the peak
Fetches per second
20M × 20% = 4M a day; 4M ÷ 86,400 ≈ 46; × 10 for the peak
≈ 46/s, 460/s at the peak
Fetches in flight at the peak
460/s × 0.8 s (arrival rate × time each takes)
≈ 370 at once, so about 8 workers at 50 connections each
Download bandwidth
4M fetches × 100 KB = 400 GB a day; ÷ 86,400 s ≈ 4.6 MB/s
≈ 37 Mbit/s on average, small
Cards held for 24 hours
4M × 1 KB; × 7 if every card took the 7-day maximum
≈ 4 GB, at most ≈ 28 GB, which fits in a cache cluster
Resized images held for 7 days
4M × 60% = 2.4M a day × 80 KB ≈ 192 GB a day; × 7
≈ 1.3 TB in object storage

Step 3 of 6: Sketch the path of one request

Step through the diagram at the top of the page to watch a request move through these parts.

A Client asks the Preview API for a card. The API cleans the URL into one standard form and looks it up in the Preview cache. A hit returns at once, and for most requests that is the whole story.

On a miss, the API hands the URL to a Fetch worker. Only one worker fetches a given URL at a time, and everyone else waits for that result. The worker never opens the connection itself. It sends the request through the Egress proxy, which sits in a network with no route to our internal systems and refuses private addresses. The proxy reaches the Remote sites. The worker reads the head of the page, pulls out the tags, writes the card to the cache and answers the API.

The card holds an image address on our own domain. When the browser asks for it, the Image proxy looks for a resized copy in the Image store. On the first request there is none, so it fetches the original through the same egress proxy, resizes and re-encodes it, and stores it. Later requests are served from the store with long cache headers.

Other paths through the system

Fetch a new page

  1. On a miss, the API hands the URL to a fetch worker. If one is already fetching this URL, the API waits for that one.
  2. The worker sends the request through the egress proxy, which resolves the name once and checks the address.
  3. The proxy connects only to a public address. It reads at most 2 MB within 5 seconds, and checks every redirect the same way.
  4. The head of the page comes back. The worker stops reading there and pulls out the title, description and image tags.
  5. It writes the card to the cache under the standard URL, with a lifetime. A failure is written too, for a few minutes.
  6. The worker returns the card to the API, which also answers everyone who was waiting.
  7. The client shows the card, with an image address on our own domain.

Load the card image

  1. The browser loads the card's image from our domain, using a hash of the original image address.
  2. The request is routed to the image proxy.
  3. The proxy looks for a resized copy in the image store. This is the first request, so there is none.
  4. It asks the egress proxy for the original image, which applies the same address checks as for pages.
  5. The original is downloaded, up to a size cap and a pixel cap.
  6. The bytes arrive at the image proxy, which decodes them, strips metadata and shrinks them to at most 1,200 px wide.
  7. It stores the re-encoded copy, so every later request is a plain read.
  8. The image goes back through the API to the browser with a long cache lifetime.

Step 4 of 6: Go deep on the hard part

Fetching hostile URLs safely

Interviewer asks

Users can paste any link. How do you stop the fetcher from being turned against your own network?

A preview service makes web requests to addresses that strangers choose.

Read more: Fetching hostile URLs safely

That is the recipe for server-side request forgery (SSRF). Someone pastes a link to an internal admin page, or to the address a cloud host uses to hand out credentials, and our server fetches it from inside our network. No single check is enough, so stack several.

  • Resolve once, then connect to that address. Look the name up yourself, check every address it returns, and connect to a checked address. If you check and then let the HTTP library look the name up again, an attacker's DNS server can answer with a safe address the first time and a private one the second (called DNS rebinding).
  • Block whole ranges. Reject loopback (127.0.0.0/8 and ::1), private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7), link-local (169.254.0.0/16, home of the cloud metadata address, and fe80::/10), the unspecified address, multicast, and IPv6 forms that wrap an IPv4 address. Parse odd spellings such as decimal or hex IPv4 into numbers before comparing.
  • Limit the scheme and the port. Allow only http and https on ports 80 and 443, and reject links that carry a user name or password.
  • Check every redirect. Turn off automatic redirects. Follow them yourself, up to 5, and run the same checks on each hop. A harmless site can redirect to a private address.

Caps. Use a 2 second connect timeout and a 5 second total timeout. Read the body as a stream and stop at 2 MB, or at the end of the head section. Check the content type before reading, and cap the size after decompression too, so a tiny compressed file cannot expand into gigabytes.

Isolation. Assume one check has a bug. Run fetch workers behind an egress proxy in a network that has no route to internal services and holds no credentials, so even a bypass reaches nothing of value. Add a limit on connections per target host, so a popular link cannot make us hammer a small site.

Identity. Send a fixed user agent that names the service and links to a page where a site owner can ask us to stop. Never send cookies or the user's credentials, because the fetch is for a public card shared by everyone.

Parsing tags and proxying the image

Interviewer asks

How do you turn a page into a card, and why not just point the client at the page's own image?

Order of trust. Parse only the head section, with a forgiving HTML parser, because real pages are messy.

Read more: Parsing tags and proxying the image

Take each field from the best source that has it: Open Graph tags first (title, description, image, site name), then Twitter card tags, then the page's title tag and meta description, and last the first large image in the body, skipping small tracking pixels. Read the character set from the response header, then from a meta tag, and default to UTF-8.

Treat every value as untrusted text. Strip control characters, cut the title to about 100 characters and the description to about 200, and never pass markup through. The client must show these strings as plain text, so a page that puts script in its title does nothing. Resolve a relative image address against the final page address, and accept only http and https image links.

Why proxy the image. If the card carries the page's own image address, every viewer's browser contacts a third-party server. That tells the site who is reading, which is a privacy leak and a tracking tool. The site can also swap the file later for something unwanted, serve a huge file, or go offline and leave a broken card. A proxy fixes all of it. The card holds an address on our domain, built from a hash of the original image address.

On the first request the image proxy fetches the original through the egress proxy, with a size cap and a pixel cap, for example 25 million pixels, so a small file cannot decode into an enormous bitmap. It then decodes the image, removes metadata such as camera location, scales it to at most 1,200 pixels wide, and re-encodes it as a standard format. Re-encoding matters for safety too: it destroys files that are valid images and also valid scripts. Refuse SVG, or draw it to a bitmap first, because SVG can carry script. Store the result under its hash and serve it with a one-year cache lifetime. The address never changes content, so a CDN can hold it.

At 80 KB a copy and 2.4 million new images a day, that is about 192 GB a day. A lifecycle rule that deletes copies not read for 7 days keeps the store near 1.3 TB.

Caching, coalescing and failures

Interviewer asks

How do you cache cards, and what happens when one link is pasted 100,000 times in a minute?

The key. Cache by the normalized URL: lowercase the scheme and host, drop the default port and the fragment, remove tracking parameters such as utm ones, and sort what is left of the query.

Read more: Caching, coalescing and failures

Keep the path as it is, since paths are case-sensitive. Two spellings of one page then share a card. When a redirect or a canonical tag shows the page's real address, store the card under that key too.

Lifetime. Keep a good card for 24 hours by default. Honour the site's own cache header inside a range of 1 hour to 7 days. Store the page's ETag, and on refresh send a conditional request. A not-modified answer extends the life and skips parsing. For popular links, serve the old card straight away and refresh it in the background, so no one waits on an expiry.

Negative caching. Failures are cached too, but for less time. A timeout or a 5xx answer is kept for about 5 minutes, and a not-found or a blocked address for about an hour. Without this, a dead link in a popular channel costs a fetch for every viewer. Store the reason so the client can show a plain link.

Coalescing. The first miss for a key takes a short lock, set only if absent, with a 10 second expiry. Every other request for that key does not fetch. It waits for the cache entry to appear, for up to 5 seconds. Inside one API node, an in-memory map of in-flight keys gives the same effect without a network call. A link pasted by 100,000 people in a minute is about 1,667 requests a second, and this turns them into one fetch. The expiry on the lock matters: if the worker dies, the lock frees itself and the next request retries.

Refresh and removal. A refresh deletes the entry or forces a conditional fetch, and is limited to once a minute per URL so it cannot be used to hammer a site. A takedown deletes the card and the image copy, and adds the URL to a blocklist checked before any fetch.

Step 5 of 6: Name the trade-offs

Where the address checks live

Checks in code, plus an egress proxy in an isolated network over Checks in application code only

If one check has a bug, the network still has no route to anything private. The cost is one more hop and one more component to run.

Preview images

Fetch once, re-encode and serve from our own domain over Let the client load the page's own image

Viewers are not tracked, files are small and safe, and links never break. The cost is storage, CPU for resizing, and a delay on the first load.

Card lifetime

24 hours, with conditional refresh over Fetch the page again on every request

Load on remote sites falls to a trickle and cards load fast. The cost is that a card can be up to a day behind the page.

Failed fetches

Cache them for minutes to an hour over Retry on every request

A dead or slow link cannot soak up workers. The cost is that a site that comes back stays missing for a short while.

Step 6 of 6: Say what breaks next

Questions an interviewer asks once the design is on the board. Answer each one out loud first.

How would you add embeds for video sites?

Many sites publish an oEmbed address that returns the embed details as data. Keep a short allowlist of trusted providers, and call it through the same egress proxy. Show the embed in a sandboxed frame, and never insert the provider's markup straight into the page.

What if the page builds its tags with JavaScript?

A plain fetch sees no tags, so the card falls back to the page title, or to a plain link. Running a headless browser for those pages is possible, but it is slow, costly and a bigger attack surface. Use it only for an allowlist of sites, in a locked-down sandbox.

How do you keep the service from being used to hammer a site?

Limit requests per client, and also connections and requests per target host. The cache already helps, because one URL is fetched once. Honour a site's request to be left alone, and back off when it answers with 429 or 503.

How do you handle links that turn out to be malicious?

Check the final address against a list of known bad sites before showing a card, and again on each refresh. A blocked link shows as plain text with a warning. The card is only a hint about the page, never a promise that it is safe.

Free ATS check

See your resume the way an ATS reads it, then fix what it misses.

Drop in a PDF for a score out of 100, every fix that cost a point, and the line it belongs to. It runs in your browser, with no signup for the score.