Unfurling @ edge: stop guessing who's a bot
A user pasted a /player/<id> link into a discussion forum and reported that the unfurl card showed the PasteWaves homepage — generic title, generic image, no track artwork. The post itself was about this specific track; the link preview suggested otherwise.
This is the SPA OpenGraph problem in one sentence: a Single-Page App serves the same index.html for every URL, so every share-card preview looks identical. A crawler hits pastewaves.com/player/abc123, reads <meta property="og:image"> from the shell — which describes the homepage — and stops. The router that would otherwise pull track-specific data only runs after JavaScript boots, and most social crawlers don’t run JavaScript.
The fix: serve one response that works for both
The deployed index.html already contains everything a human needs — <head> with meta tags, stylesheet link, hashed JS bundle, <div id="root"></div> for React to mount into. The only thing wrong with it for share previews is that the <meta> tags describe the homepage instead of the page being shared. So take the actual SPA shell, rewrite the head, return it to everyone. Crawlers parse the head, read og:title, og:image, og:description, and stop. Browsers parse the head too, then continue to the script tags and boot React. Same bytes, both happy.
Lambda@Edge on a viewer-request trigger is the right place because it sits between CloudFront and the origin and can synthesise a response without ever forwarding to S3. The handler:
export const handler = async (event) => {
const request = event.Records[0].cf.request;
const playerMatch = request.uri.match(/^\/player\/([a-f0-9-]+)/);
if (!playerMatch) return request; // not a player URL → pass through
const fileId = playerMatch[1];
// Fetch track metadata + the deployed SPA shell in parallel
const [metaResp, shellResp] = await Promise.all([
fetch(`${METADATA_API}${fileId}`),
fetch("https://pastewaves.com/index.html"),
]);
if (!metaResp.ok || !shellResp.ok) return request; // any failure → pass through
const file = await metaResp.json();
if (file.processingStatus !== "completed") return request;
let html = await shellResp.text();
html = replaceMetaProperty(html, "og:title", `${file.title} - PasteWaves`);
html = replaceMetaProperty(html, "og:description", file.description);
html = replaceMetaProperty(html, "og:image", file.artwork4x3Url || file.artworkUrl);
html = replaceMetaProperty(html, "og:audio", audioUrl(fileId));
html = replaceMetaProperty(html, "og:type", "music.song");
// …og:url, twitter:*, canonical, JSON-LD AudioObject, etc.
return {
status: "200",
headers: {
"content-type": [{ key: "Content-Type", value: "text/html; charset=utf-8" }],
"cache-control": [{ key: "Cache-Control", value: "public, max-age=3600" }],
},
body: html,
};
};
replaceMetaProperty is a small helper that finds the existing <meta property="…"> tag in the shell and swaps its content, falling back to inserting one before </head> if the tag isn’t there. The helpers and regexes — including a JSON-LD swap that replaces the homepage’s Schema.org WebApplication block with an AudioObject — fit in about 150 lines. No external dependencies, no AWS SDK, just fetch.
Why this beats the canonical approach
The pattern most Lambda@Edge OpenGraph tutorials recommend is to detect crawlers by User-Agent at the edge and synthesise a different HTML for them. A regex against User-Agent: if it matches facebookexternalhit, Twitterbot, LinkedInBot, WhatsApp, Slackbot, Discordbot, return a synthetic stub with track-specific <meta> tags. Otherwise pass through to the SPA. It works for the named bots.
It stops working at the first legitimate aggregator that’s deliberately not in the regex. Discourse — the open-source forum platform behind Hugging Face’s community, the Rust language forum, and most modern OSS project forums — sends a Safari User-Agent on purpose when generating onebox previews, to dodge anti-bot blocks. A legitimate aggregator is deliberately impersonating a browser; no regex can tell it apart from a real one. Apple Messages does something similar with a generic AppleWebKit UA. UAs are forgeable, IP ranges aren’t a clean separator, Accept-header heuristics break at the first edge case. Any binary “bot or not” decision will be wrong some of the time, and the failure mode is silent — the user sees a sad-looking unfurl and doesn’t tell you.
Returning the same response to everyone has four properties the UA-sniffing version doesn’t:
Per-URL caching, not per-(URL × UA). The synthetic response carries Cache-Control: public, max-age=3600, so CloudFront caches it keyed on path alone. One miss per fileId per hour, then warm. The UA-sniffing version has to forward User-Agent as part of the cache key, which means every crawler variant is its own miss — cache hit rates are terrible and Lambda@Edge invocation cost climbs with bot diversity.
No redirect flash. The bot-detection version has to bridge real users through to the SPA from its synthesised stub, typically with <meta http-equiv="refresh" content="0;url=…">. That produces a visible “Redirecting…” flash on slower connections. Returning the actual SPA HTML from the start removes it.
Asset URLs aren’t hardcoded. The React bundle is content-hashed (/static/js/main.984b2648.js) and changes every deploy. Because we fetch the live index.html instead of templating one inside the Lambda, webapp deploys “just work” — there’s no Lambda republish needed to pick up a new bundle hash.
Failures are invisible. Every error path — metadata API down, shell fetch fails, file still processing, anything throws — falls back to return request, which lets CloudFront serve the static index.html exactly as it would have. The unfurl in that failure case is the homepage card, which is the same outcome as the pre-fix world. No 500s, no broken player links, no on-call. The fall-through is the safety net.
What Lambda@Edge costs you
Lambda@Edge is the right tool here, but it’s worth being honest about what you sign up for:
Pricing is roughly 3× regular Lambda. Lambda@Edge invocations bill at ~$0.60 per 1M requests plus ~$0.0000125125 per 128 MB-second, against ~$0.20 per 1M for a regular Lambda in the same region. At PasteWaves scale this is rounding error, but a high-traffic SPA running this pattern on every viewer request can absolutely move the AWS bill — pay attention to your CloudFront cache hit rate, because every cache miss is a billable Lambda invocation.
It only runs in us-east-1, no matter where your app lives. CloudFront is a global service rooted in us-east-1, and Lambda@Edge functions must be deployed there even if the rest of your stack is in us-east-2 or eu-west-1. The function then replicates to every edge location automatically, but your Terraform, IAM roles, and CloudWatch log groups all live in us-east-1. CloudWatch logs additionally land in whatever region the request was served from, which means debugging a single failure can mean checking 10+ regional log groups before you find the right one.
Node.js or Python only. No Go, no Java, no custom runtimes, no container images. If your team writes Lambda functions in Go everywhere else (we do), this one is the odd one out.
No environment variables. You read this right. Lambda@Edge functions cannot use Lambda environment variables, so any configuration — API URLs, table names, feature flags — has to be hardcoded into the function source and republished on change. Workarounds exist (fetch config from a public URL at cold start, bundle a config module) but each is a complication.
Tight body and timeout limits. On viewer-request and viewer-response, synthetic response bodies are capped at 40 KB uncompressed and the function must complete in 5 seconds with 128 MB of memory. origin-request and origin-response triggers raise these to 1 MB / 30s / 10 GB respectively, but in exchange the function only runs on cache misses (not every viewer request). PasteWaves’ shell is ~6 KB minified and ~5 KB after the swap — comfortable headroom — but a heavier SPA shell would push you to the origin-response trigger, which works for the same goal but has a different deployment model.
Deploys are slower and have more moving parts than regular Lambda. Every code change requires publishing a new versioned ARN (Lambda@Edge cannot use $LATEST), then updating the CloudFront distribution’s lambda_function_association to point at the new version, then waiting 2-3 minutes for CloudFront to roll the change out to every edge. A typo means a 5-minute round trip, not a 10-second redeploy. Roll-back means publishing yet another version. There’s no canary, no traffic-shifting — just full edge-wide propagation.
When this pattern fits
This design is the right tool when (a) you can’t afford to migrate to an SSR framework and (b) the surface area that needs per-route OG tags is small (one or two URI patterns). If you’re rewriting half your routes, the framework migration is probably cheaper.
If you’re scoping this work right now and the canonical tutorial has you reaching for a User-Agent regex, the design above is the version that doesn’t break the next time someone ships a new social-preview crawler.