Content-hashed keys: cache storefront images forever, change them anytime
Design product image caching at the key level, not the header level: immutable content-hashed paths, a short-lived manifest, and a CDN doing the actual edge work.
The storage layer’s contribution to a caching strategy is smaller than most guides suggest, and it’s this: make every URL immutable. Name each object after a hash of its bytes, never overwrite a key, and point the storefront at a manifest that changes when the picture changes. Then max-age=31536000, immutable is safe forever and you never purge a CDN again. Infrai’s object store fits that design as the origin of record — with one boundary worth knowing before you build on it.
It won’t set response headers for you.
There’s no route to attach Cache-Control to a stored object, set_acl accepts only private, and the presigned link it hands back arrives with Content-Disposition: attachment, so a browser downloads the file instead of rendering it in an <img>. That combination means public image delivery has to terminate somewhere you control — your own route, or a CDN in front of a public-capable origin. Design accordingly and the rest of this is straightforward.
Step one: hash the bytes, put them at that key
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { Buffer } from "node:buffer";
const TOKEN = process.env.INFRAI_API_KEY;
if (!TOKEN) throw new Error("INFRAI_API_KEY is not set");
const BUCKET = "kb-cdn-0726";
export async function publishImage(sku, filePath) {
const bytes = await readFile(filePath);
const digest = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
const key = `products/${sku}/${digest}.png`;
const payload = {};
payload.data_base64 = bytes.toString("base64");
payload.content_type = "image/png";
const res = await fetch(`https://api.infrai.cc/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!res.ok || json.ok !== true) {
throw new Error(`publish ${key}: HTTP ${res.status} ${JSON.stringify(json.error ?? json)}`);
}
return { key, etag: json.data.etag, size: json.data.size_bytes };
}
console.log(await publishImage("sku-4821", "hero.png"));
{
"ok": true,
"data": {
"bucket_id": "bkt_ff8c812ca3374e0890b381",
"key": "products/sku-4821/c414cd0e204d.png",
"size_bytes": 70,
"etag": "2cd8bde463f5d82aae0f0cec061d6b8f",
"content_type": "image/png",
"metadata": null,
"created_at": "2026-07-26T01:02:02.556023Z"
}
}
Re-uploading identical bytes produces the identical key, so the publish step is naturally idempotent — run your import job twice and nothing changes. Upload a new photo for the same SKU and you get a new key beside the old one, which is the point: the old URL stays valid for anyone mid-session with it cached, and nothing has to be invalidated anywhere.
Step two: a manifest that carries the freshness
Something has to be mutable, or the storefront can never learn about the new picture. Concentrate all of that mutability in one small document and give it a short TTL.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/object/get/kb-cdn-0726/catalog/manifest.json" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That returns the usual envelope with the document in data.data_base64; decoded, it’s this:
{
"generated_at": "2026-07-26T01:05:00Z",
"products": {
"sku-4821": {
"image_key": "products/sku-4821/c414cd0e204d.png",
"etag": "2cd8bde463f5d82aae0f0cec061d6b8f"
}
}
}
Two tiers, two policies. The manifest gets max-age=60 — or lives in your database, which is usually simpler — and the images get a year with immutable. A shopper’s browser then revalidates one 4 KB JSON document per minute, at most, and never re-fetches a 300 KB photo it already has.
| Strategy | Edge cacheable | Cost of a change | Works on Infrai storage today |
|---|---|---|---|
| Overwrite the key, purge the CDN | yes, until you purge | a purge API call plus propagation lag | yes, but purging is your CDN’s job |
?v=3 query string on a stable path | patchy — some caches ignore the query | none | yes |
| Content hash in the path | yes, immutable safe | none, old URL stays valid | yes |
Presigned URL as the <img src> | no — signature expires, 7-day ceiling | new URL per expiry | works, but forces a download |
The query-string trick is the one to be suspicious of. Plenty of intermediaries strip or ignore query parameters when they decide what to store, and the failure is invisible: your CDN reports a healthy hit rate while a fraction of users see last month’s product shot.
What a presigned link actually returns
Worth showing, because the reflex is to hand the signed URL straight to the browser. Ask for one with a 7-day TTL:
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/kb-cdn-0726/products/sku-4821/c414cd0e204d.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":604800}'
Then look at the headers it serves:
HTTP/1.1 200 OK
Content-Type: image/png
Content-Length: 70
Accept-Ranges: bytes
Content-Disposition: attachment
ETag: "2cd8bde463f5d82aae0f0cec061d6b8f"
Last-Modified: Sun, 26 Jul 2026 01:02:02 GMT
Server: tencent-cos
x-amz-force-download: true
No Cache-Control at all, and x-amz-force-download turns the click into a save dialog. Also, 604800 seconds is the ceiling — one second more and you get STORAGE_INVALID_TTL:
{
"ok": false,
"error": {
"code": "STORAGE_INVALID_TTL",
"http_status": 400,
"message": "ttl_seconds 604801 out of range [1..604800]",
"retryable": false
}
}
Step three: your route owns the headers
Since the origin doesn’t set them, set them yourself. The handler below reads the object through the API, sends it with a one-year immutable policy, and answers conditional requests with a 304 so repeat visits transfer nothing.
import { createServer } from "node:http";
import { Buffer } from "node:buffer";
const TOKEN = process.env.INFRAI_API_KEY;
if (!TOKEN) throw new Error("INFRAI_API_KEY is not set");
const BUCKET = "kb-cdn-0726";
async function loadObject(key) {
const res = await fetch(`https://api.infrai.cc/v1/storage/object/get/${BUCKET}/${key}`, {
method: "GET",
headers: { Authorization: `Bearer ${TOKEN}` },
});
const json = await res.json();
if (!res.ok || json.ok !== true) throw new Error(`get ${key}: HTTP ${res.status}`);
if (json.data.found !== true) return null;
return Buffer.from(json.data.data_base64, "base64");
}
createServer(async (req, reply) => {
const key = decodeURIComponent(new URL(req.url, "http://localhost").pathname.replace(/^\/img\//, ""));
const tag = `"${key.split("/").pop().replace(/\.\w+$/, "")}"`;
if (req.headers["if-none-match"] === tag) {
reply.writeHead(304, { ETag: tag, "Cache-Control": "public, max-age=31536000, immutable" });
return reply.end();
}
try {
const body = await loadObject(key);
if (!body) {
reply.writeHead(404, { "Cache-Control": "public, max-age=30" });
return reply.end("not found");
}
reply.writeHead(200, {
"Content-Type": "image/png",
"Content-Length": body.length,
ETag: tag,
"Cache-Control": "public, max-age=31536000, immutable",
});
reply.end(body);
} catch (err) {
console.error(err);
reply.writeHead(502, { "Cache-Control": "no-store" });
reply.end("upstream error");
}
}).listen(8080);
The ETag is derived from the content hash already sitting in the key, so it costs nothing to compute and can’t drift from the bytes. Put any CDN in front of this route and the origin sees one request per image per edge location, forever.
Confirming the object and its identity
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-cdn-0726/products/sku-4821/c414cd0e204d.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "products/sku-4821/c414cd0e204d.png",
"size_bytes": 70,
"etag": "2cd8bde463f5d82aae0f0cec061d6b8f",
"content_type": "image/png",
"metadata": null,
"last_modified": "2026-07-26T01:02:02Z"
}
}
head is free and tells you the recorded MIME type, which object/list unhelpfully reports as null. Use it in a post-deploy check that every key in the manifest resolves before you flip the storefront over.
Costs, checked today
Verified 2026-07-26: object/put is $0.0001 per call and object/get $0.0002 — reads run about double writes — while head, list, presign, bucket/create and bucket/usage are free and rate-limited. New accounts get $2 of credit, roughly 19,999 uploads’ worth. A catalogue of 5,000 product images therefore costs about $0.50 to publish, plus metered GB for storage and egress. That last part is where a CDN pays for itself: cached edge hits never reach the origin, so the bill flattens as traffic grows. Rates in this market drift downward and campaigns run, so read the live figure rather than this page:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd','free')) for c in d['capabilities'] if c['id'] in ('storage.object.put','storage.object.get')])"
Where you’d be better off elsewhere
If the storefront is high-traffic and the images are unambiguously public, Cloudflare R2 as the origin is the stronger pick: zero egress fees to the internet, real public buckets, and Cache-Control you can set per object at upload time — none of which Infrai’s storage does today. S3 with CloudFront is the same argument with more knobs and an egress bill. If you also want on-the-fly resizing, format negotiation and art direction, Cloudinary does that job properly and this whole design becomes a subset of what you’re paying them for.
Infrai earns its place when storage is one of several things you need behind one key: the cron that rebuilds the manifest, the queue that runs image imports, the error tracking that catches a failed publish and the bucket itself are one account and one invoice. For a pure public-image CDN origin, use a specialist and keep this pattern — the content-hash key is portable to every one of them.