Every guide to building a media downloader stops at the same place: call the API, take the CDN URL, hand it to the browser. That works in a demo and hurts in production for three unglamorous reasons.
- Upstream URLs expire. The link you were given is good for a short window. A user who leaves the tab open, or opens a bookmark tomorrow, gets an error page.
- Your API key ends up in devtools the moment the browser talks to the provider directly.
- Nobody is watching the bill. When a provider charges per record, one enthusiastic visitor can cost more than a month of normal traffic.
None of this is specific to any one platform — it applies to any product built on top of someone else's signed URLs and per-record pricing. These are the three patterns I settled on.
Pattern 1: the client never sees an upstream URL
Route every piece of media through your own path and fetch it server-side:
GET /media/<id> → fetch(upstream URL + service credentials) → stream bytes back
Four things fall out of this, and all four are worth the extra hop:
- The credential stays on the server. The browser only ever sees your domain.
- The link you hand a user stops expiring. You're presenting a stable identifier of your own, not a signed URL with a countdown.
- You control the caching headers. If the upstream object is immutable (a stored file, not a live feed), it can be cached at the edge and in the browser for a long time, and the second request never reaches the provider.
-
Path traversal is easy to close. Validate each path segment against a strict allowlist (
[A-Za-z0-9_-]plus a file-extension check) and the route becomes provably incapable of fetching anything except the records you intend to serve.
Two implementation notes that cost me time:
- Stream, don't buffer. Pass the upstream body straight into the response. Buffering a large video into memory to add a header is a good way to hit runtime memory limits.
-
Don't assume the upstream content type. Forward what the provider sends, with a sane fallback, or downloads land as
application/octet-streamand some mobile browsers refuse them.
Pattern 2: one adapter per entity, normalise at the boundary
The tempting design is one provider, one client, one code path. In practice each kind of data you need is a different product: different schema, different auth, different price, and frequently a different vendor.
So: one adapter per entity, all behind a single internal interface, all mapping into one internal shape. The UI then never learns where the data came from, and:
- swapping a vendor is a new adapter, not a rewrite;
- you can benchmark vendors per entity instead of per service — I found the same entity priced more than an order of magnitude apart depending on who serves it, which is not something you notice while reading landing pages;
- and you can keep a mock adapter forever, so local development never burns credits or blocks on a slow upstream job.
That last point sounds minor until your first provider call takes tens of seconds because the upstream worker was idle. Design the UI around that reality — show the loading state, cache the result, and never make the same visitor wait twice for the same query.
Pattern 3: rate limit by cost, not by request count
"100 requests per minute" is the wrong unit when the provider bills per record. A request that returns 5 items and one that returns 500 should not cost the same, and you don't want to maintain a separate limit per endpoint.
The fix is to push pricing into the data contract: every adapter also returns a cost multiple for the entity it just served, expressed relative to the cheapest entity (which is 1). The limiter then charges:
cost = max(1, items returned) × cost multiple
Two benefits: one limiter covers every entity, and the abuse ceiling tracks your actual spend instead of your request count. When a provider changes prices, you change one number, not a set of thresholds.
Keep the response out of the bookkeeping
The other half of staying cheap is not doing work on the request path that can happen after it. Read what you need, return the response, and let the platform finish the rest — metrics, counters, cache writes. Most modern edge runtimes give you a way to schedule that work past the response, and using it is the difference between a download that starts instantly and one that waits on an analytics write.
If your counters have to serialise writes, look for a per-day object or a small stateful class instead of round-tripping to a database on every request.
What I'd tell myself earlier
- Benchmark per entity, not per vendor. The gap is usually in the footnotes — start fees, platform fees, per-result vs per-page.
- Keep the mock adapter. Cheapest way to keep local development honest.
- Make the cost part of the adapter's return value and your limits stop being guesses.
- Assume the upstream link will be dead by the time someone clicks it. Everything above follows from that assumption.
I ended up with a small toolkit built exactly this way — a profile viewer, a story viewer and a couple of audio tools all sit on the same request path, and adding the next entity is an adapter rather than a new project.
Top comments (0)