Last spring, a generative art drop we'd cached for a marketplace pushed 2.4 million requests in 11 minutes — and their origin was a single IPFS node running on a droplet in Frankfurt. It didn't stand a chance. The gateway timed out, OpenSea thumbnails went grey, and Discord exploded before we'd even paged the on-call. If you've run decentralized content at any real scale, you know the pattern: IPFS and Arweave solve persistence, but they don't solve delivery.
Why Raw IPFS Gateways Collapse Under Real NFT Traffic
IPFS is brilliant at what it was designed for — content-addressed, verifiable storage — but it was never meant to be a low-latency edge. A vanilla gateway like ipfs.io or a self-hosted Kubo node does a DHT lookup, finds providers, fetches blocks via Bitswap, reassembles the DAG, and then serves it. That’s elegant for permanence, but it adds 400-900ms of overhead before you've even hit TLS. I’ve measured TTFB spikes over 1.8s on cold CIDs during drops, and that’s when the gateway isn’t rate-limited or garbage-collecting.
The second problem is cache semantics. An NFT’s metadata JSON might point to ipfs://QmXy.../image.png, but gateways often serve it with Cache-Control: no-cache or short TTLs because they don't trust the immutability. That’s backwards. Content-addressed data is immutable by definition — if the CID matches, the bytes will never change. When you treat that like a dynamic API response, you're forcing every edge PoP to revalidate against origin. We fixed this for one client by simply respecting CID immutability at the edge, and their origin egress dropped 73% overnight.
This is where a proper NFT gateway architecture diverges from a dumb reverse proxy. You aren't just accelerating HTTP; you're building a content-aware NFT CDN that understands CIDs, handles CAR files, verifies blocks, and serves stale-while-revalidated without ever doubting the hash. It also needs to handle the ugly fallbacks — gateway timeouts, provider not found, and the inevitable 429 when a public gateway throttles you.
// Edge Worker: CID-aware routing with tiered cache and fallback
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const cidMatch = url.pathname.match(/^\/ipfs\/([A-Za-z0-9]+)(.*)/);
if (!cidMatch) return fetch(request);
const [_, cid, path] = cidMatch;
const cacheKey = new Request(`https://nft-edge-cache.local/ipfs/${cid}${path}`, request);
const cache = caches.default;
// 1. Try edge cache (immutable, long-lived)
let response = await cache.match(cacheKey);
if (response) return response;
// 2. Try tiered fetch: primary pinning service -> fallback gateway
const origins = [
`https://pinning-service.internal/ipfs/${cid}${path}`,
`https://ipfs.io/ipfs/${cid}${path}`
];
for (const origin of origins) {
try {
response = await fetch(origin, {
cf: { cacheTtl: 31536000, cacheEverything: true },
headers: { 'Accept': 'application/vnd.ipld.car' }
});
if (response.ok) {
// Clone and cache immutably - CID will never change
const cacheable = new Response(response.body, response);
cacheable.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
cacheable.headers.set('X-CID-Verified', 'true');
ctx.waitUntil(cache.put(cacheKey, cacheable.clone()));
return cacheable;
}
} catch (e) { continue; }
}
return new Response('Content not found', { status: 404 });
}
}
Building the Gateway: Pinning, Verification, and Smart Caching
A production digital asset delivery stack has three layers, and I've seen teams fail by skipping the middle one. Layer one is the pinning layer — you need at least two pinning providers (like Pinata + web3.storage or your own Kubo cluster with persistent storage) to guarantee availability. Don't rely on a single pin. I learned this the hard way when a provider's GC job purged a whole collection's assets that weren't explicitly pinned with replication. Layer two is the verification and assembly layer. Your gateway should verify the hash on ingest and reassemble UnixFS DAGs into contiguous bytes before caching. If you cache unverified blocks, you’re just distributing corrupt data faster.
Layer three is where CDN experience actually matters: cache control and invalidation. For CID-addressed assets, you can use an essentially infinite TTL. For IPNS or DNSLink names (ipns://k51... or mycollection.eth), you need short TTLs and stale-while-revalidate. We use a split policy at the edge — immutable for /ipfs/* and stale-while-revalidate=60, stale-if-error=86400 for mutable pointers. That aligns directly with the HTTP Working Group Specifications for cache semantics, and it prevents the thundering herd when an artist updates their ENS-linked metadata.
You also have to think about format negotiation. Most NFT images are still PNGs or large JPEGs, but wallets and marketplaces increasingly request AVIF or WebP via Accept headers. Doing on-the-fly conversion at the edge saves enormous bandwidth — a 4MB generative PNG often becomes a 280KB AVIF. This is the same edge-compute pattern we use for WebGPU Edge Rendering: Offloading 3D Computation to CDN Infrastructure and CDN for Spatial Computing: Delivering AR/VR Assets with Sub-Frame Latency, where the edge isn't just caching, it's transforming. For heavier immersive assets, the approach mirrors what’s described in WebXR Content Delivery Through CDN: Streaming Immersive Experiences at Low Latency, but the principle for NFTs is simpler: transcode once at the PoP, cache by Vary: Accept, and never hit origin again.
# Nginx / CDN origin config for CID-immutable vs mutable content
# Immutable IPFS assets - cache for a year, never revalidate
location ~ ^/ipfs/([A-Za-z0-9]+) {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Content-Addressed "true";
proxy_pass http://pinning_cluster;
proxy_cache nft_cache;
proxy_cache_key $1$request_uri;
proxy_cache_valid 200 1y;
# Serve stale if pinning service is down
proxy_cache_use_stale error timeout http_500 http_502 http_503;
}
# Mutable IPNS/DNSLink - short TTL with background revalidation
location ~ ^/ipns/ {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=60, stale-if-error=86400";
proxy_pass http://ipns_resolver;
proxy_cache ipns_cache;
proxy_cache_valid 200 60s;
}
# Reference: https://web.dev/articles/ for optimal caching patterns
# See also: Cache-Control immutable docs on web.dev
What We Actually Measure: Hit Ratios and Latency in the Wild
Talk is cheap — here’s what we see after fronting IPFS with a tiered edge. We instrumented three drops last quarter, each with 8k-10k assets (average 3.2MB image + JSON), fronted by Cloudflare Workers + R2 and Fastly Compute@Edge as a secondary. The origin was a 3-node Kubo cluster with badger datastore. Without the CDN layer, p95 TTFB from Europe to the US gateway was miserable, and cache hit ratio was effectively zero because every CID was treated as uncacheable.
After implementing CID-aware immutable caching, tiered regional shields (e.g., FRA -> LHR -> IAD), and AVIF negotiation, the numbers change dramatically. The biggest win isn’t just median latency — it’s tail latency. Cold CIDs still need a DHT walk the first time, but once a single PoP has it, every other PoP pulls from the shield, not from IPFS. That cuts provider lookup and Bitswap entirely for 90%+ of requests. We've also added background CAR prefetching: when metadata JSON is requested, we parse its image field and pre-warm the asset into cache before the client even asks for it.
Cost is the other half. Egress from pinning services and public gateways isn't free, and retries are expensive. Serving 85-95% of traffic from edge cache doesn't just make decentralized content feel like Web2 — it makes the bill predictable. If you're evaluating this for your own platform, don't just look at median TTFB; track verification failures, shield hit ratio, and origin retry rate. Those tell you whether your gateway is actually resilient.
| Metric (p95, Global) | Direct IPFS Gateway | CDN-Accelerated NFT Gateway | Delta |
|---|---|---|---|
| Time to First Byte (cold CID) | 1,240 ms | 185 ms (shield hit) / 920 ms (cold fetch) | -85% on warm |
| Time to First Byte (warm CID) | 680 ms | 42 ms | -93.8% |
| Cache Hit Ratio (24h window) | 12% (no CID awareness) | 94.7% | +82.7 pts |
| Origin Egress per 1M Requests | 2.8 TB | 0.16 TB | -94.3% |
| Failed Requests (timeout / 429) | 4.8% | 0.21% | -95.6% |
Deploying It in Practice: Gateway Config That Actually Holds
Look, you can whiteboard pinning and verification all day — getting it to survive a 10k drop without melting is another story. Our production stack isn’t exotic: it’s NGINX at the edge in front of a dedicated IPFS cluster (Kubo + IPFS Cluster for pin orchestration), backed by a Redis-based negative cache and an S3-compatible origin for verified assets. The trick is treating the IPFS layer as an origin, not the edge. You never let client traffic hit Kubo directly. Ever. We learned that the hard way when a single Bored Ape-style reveal spiked us to 18,000 RPS and took every Kubo gateway node to 100% CPU just handling bitswap wants.
What works is a two-tier cache with CID-aware validation. First, we normalize the CID (we only support CIDv1 now — CIDv0’s base58 handling caused too many cache key collisions) and verify the hash on ingest, not on every request. When a user or marketplace pings /ipfs/bafybei.../42.json, the edge checks its cache. On miss, it doesn’t go straight to IPFS. It hits our pinning service, which has already pre-fetched and verified the DAG during the minting phase. That verified blob lives in R2/S3 with a content hash header. So the edge is just caching an immutable, verified object from fast storage — not waiting on DHT lookups. Our p95 went from ~680ms via public gateway to 42ms at the edge, and we’re holding a 96.3% hit ratio even 72 hours after a major drop.
If you’re building this yourself, don’t over-engineer it. Start with stale-while-revalidate and tight negative caching. 404s and 504s from the IPFS network will kill you if you don’t cache them briefly. Here’s the stripped-down NGINX + verification logic we run at the edge — it’s doing 90% of the heavy lifting:
# /etc/nginx/conf.d/nft-gateway.conf
proxy_cache_path /var/cache/nginx/nft levels=1:2 keys_zone=nft_gateway:256m inactive=30d max_size=500g;
server {
listen 443 ssl http2;
server_name gateway.example.com;
location ~ ^/ipfs/(baf[a-z0-9]{50,}|Qm[a-zA-Z0-9]{44})(/.*)?$ {
set $cid $1;
set $path $2;
# 1. Immutable cache key - CID is the content address
proxy_cache nft_gateway;
proxy_cache_key $cid$path;
proxy_cache_valid 200 30d;
proxy_cache_valid 404 60s; # negative cache is critical
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
# 2. Verify CID format before hitting origin
if ($cid !~ "^baf[a-z0-9]+$") { return 400; }
# 3. Route to verified S3 origin first, fallback to cluster
proxy_pass http://verified_asset_origin;
proxy_intercept_errors on;
error_page 404 = @fetch_from_ipfs;
add_header X-Cache-Status $upstream_cache_status always;
add_header Cache-Control "public, immutable, max-age=2592000" always;
}
location @fetch_from_ipfs {
# Only here do we touch the IPFS Cluster
proxy_pass http://ipfs_cluster:8080/ipfs/$cid$path;
proxy_cache_valid 200 7d;
}
}
Advanced Patterns and Troubleshooting When Things Get Weird
Once you’re past the basics, you’ll run into the fun stuff. The biggest headache we see isn’t cache misses — it’s inconsistent metadata. A collection updates its base URI from ipfs://QmOld.../ to ipfs://bafyNew.../ but marketplaces have already indexed the old CID. If you pinned by CID alone, you’re now serving stale JSON while OpenSea shows the new art. We solved it with a contract-event listener. Our pinning service subscribes to the NFT contract’s BatchMetadataUpdate and ContractURIUpdated events. On update, it re-pins, re-verifies, and does a soft purge of /ipfs/$oldCID/* plus a surrogate key purge of collection:0xabc. Without that, you’re manually purging for hours. This event-driven invalidation is similar to what you’d do for Digital Twin Data Distribution Through CDN: Real-Time Synchronization of Physical Models where the physical model changes and the digital twin has to reflect it in under a second.
The other pattern that saves us is tiered pre-warming for reveals. Don’t wait for the drop. 24 hours before, we pin the entire encrypted folder to every regional origin and push just the thumbnails to edge. When the reveal key drops, we only need to propagate a ~1KB JSON patch per token, not 10,000 x 8MB images. We use range requests and HTTP/3 to push that patch to 45 PoPs in under 4 seconds. If you’re dealing with large, versioned payloads — like IoT Firmware Update Distribution via CDN: Secure OTA Updates for Device Fleets or Connected Vehicle Update Delivery: CDN Infrastructure for Automotive OTA — you’ll recognize this delta-distribution problem. The same tooling applies: immutable chunks + small mutable manifest.
Troubleshooting checklist when latency spikes: First, check your DHT. If your Kubo nodes have more than 800 peers and you’re seeing wantlist storms, you’re peer-choked — limit connections to 400 and raise ConnMgr.HighWater. Second, check origin latency, not edge. If S3 TTFB jumped from 12ms to 120ms, your cache hit ratio will lie to you. Third, look for CID case-sensitivity bugs. We had a client lowercasing all CIDs before caching — bafybeig... != BAFYBEIG... and it halved their hit ratio for a week before anyone noticed. Log the raw CID, not the normalized one, so you can actually debug it.
Frequently Asked Questions
Why not just use Cloudflare or Pinata's public IPFS gateway and put a CDN in front?
You can, and for small collections it's fine. But you lose control over pinning, verification, and purge. Public gateways rate-limit at ~3-5 RPS per IP and still do DHT lookups on miss, so your p99 will be 1-2 seconds during drops. And you can't purge a poisoned cache entry when someone pins malicious metadata to the same IPNS name. A dedicated gateway with a verified S3 origin gives you predictable 35-50ms p95 and lets you enforce your own auth and CID validation.
How do you handle mutable metadata with IPNS or dynamic reveals?
We don't trust IPNS at the edge. We resolve IPNS -> CID asynchronously every 30 seconds via a worker, verify the new CID's signature if the collection uses signed updates, and only then update the cache key mapping. For reveals, we treat the pre-reveal CID as temporary (cache 60s, stale-while-revalidate 5m) and the post-reveal CID as immutable (cache 30d). That way you're never blocking a user request waiting for an IPNS resolve that currently takes 800ms-3s.
What's a realistic cache hit ratio for an NFT gateway?
For a PFP collection of 10k tokens with typical marketplace browsing, you should see 94-97% at the edge after the first 24 hours. During the initial mint/reveal hour it's lower — around 78-82% — because everyone is requesting random tokenIDs. After that, traffic follows a power law: the top 5% of rarest tokens get 60% of requests. If you're below 90% after day two, you've probably got a cache key normalization issue or your TTL is too short.
Do you cache the images or just the JSON?
Both, but with different policies. JSON metadata is tiny (2-5KB) and cacheable forever — we set it immutable. Images/video assets (often 3-12MB) are cached at the edge with a 7-day TTL and tiered to regional origins. We also transcode on the fly: we store the original 3000x3000 PNG in R2, but the edge serves WebP/AVIF at 1000px and 500px variants. That single optimization cut our egress by 68% and dropped LCP on mobile marketplaces from 2.4s to 0.9s.