Choose immutable Open Graph card URLs derived from the normalized title, crop contract, and source-image identity. That makes a title edit produce a new card while ordinary reads keep the old bytes at the edge. For an e-commerce catalog that smart-crops one product image into several aspect ratios, this is the useful split: spend computation and bandwidth only when an input that can change pixels has changed.
TL;DR: hash a canonical render manifest, store each output under that digest, and point page metadata at the resulting immutable URL. Do not use the page URL, update time, or a blind time-to-live as the image identity. A timestamp regenerates cards after unrelated inventory edits; a page URL can leave stale cards behind after a title edit. The manifest says exactly why the pixels differ.
Before: every product update invalidates every crop, so changing stock from 8 to 7 can trigger another 1.91:1 card render. After: the title, image identity, crop policy, dimensions, and encoder choice form one digest. Stock is absent. No pixel change, no new key.
How should you cache Open Graph share card images?
A product ID identifies the subject, not a particular image. If a stable card URL always means the current card, a cache can retain an older response while the origin has newer pixels. Shortening the cache lifetime reduces that window, but it also trades away cache hits and adds repeated transfers.
A content-derived key removes that ambiguity. A path containing the product ID and digest names one rendering. When the title changes, the digest changes and the HTML points to a different URL. Existing shares may keep requesting the old object, which is fine because that URL still names the old rendering. New page reads discover the new one.
This is where quality versus bandwidth becomes concrete. One giant image resized by every consumer wastes bytes. One aggressively compressed image used everywhere can damage small product details or text. Create a small, declared set of outputs instead: for example, a 1200 by 630 Open Graph card, a 1080 by 1080 square card, and a 1200 by 1500 portrait card. Those exact dimensions are an application decision in this example, not a universal platform rule.
The diagram in words is short: product change event to canonical manifest to digest to one render job to immutable objects to metadata URL. Reads go from metadata URL straight to the cached object. The crop worker is off the hot path.
That is the boundary.
Build one manifest that explains every pixel
Start with a narrow contract. The title needs normalization before hashing so invisible input differences do not create surprise variants. Keep the original source-image identity in the manifest as well; otherwise a photography update could reuse the title's old card. A crop-policy version gives deliberate algorithm changes a clean invalidation path.
import { createHash } from "node:crypto";
type Ratio = "og" | "square" | "portrait";
type CropSpec = { width: number; height: number; quality: number };
type RenderManifest = {
productId: string;
title: string;
sourceImageId: string;
cropPolicy: string;
encoder: "webp";
outputs: Record<Ratio, CropSpec>;
};
const normalizeTitle = (title: string): string =>
title.normalize("NFC").trim().replace(/\s+/g, " ");
const stableManifest = (input: RenderManifest): RenderManifest => ({
...input,
title: normalizeTitle(input.title),
});
const manifestDigest = (manifest: RenderManifest): string =>
createHash("sha256")
.update(JSON.stringify(stableManifest(manifest)))
.digest("hex")
.slice(0, 24);
const manifest: RenderManifest = {
productId: "sku-1842",
title: "Trail Bottle, 750 ml",
sourceImageId: "photo-42-rev-3",
cropPolicy: "saliency-v2",
encoder: "webp",
outputs: {
og: { width: 1200, height: 630, quality: 82 },
square: { width: 1080, height: 1080, quality: 80 },
portrait: { width: 1200, height: 1500, quality: 80 },
},
};
const digest = manifestDigest(manifest);
const objectKey = (ratio: Ratio): string =>
"cards/" + manifest.productId + "/" + digest + "/" + ratio + ".webp";
The fixed object shape matters. A production boundary should construct the manifest exactly as shown rather than hash arbitrary inbound JSON. Do not let clients add fields whose order or meaning is unknown. Validate first, then build the canonical object.
Notice the trade-off in the key. Encoder quality belongs there because it changes bytes and visible output. Inventory count does not. Alt text does not. The product ID stays in the path for operations, while the digest carries identity. Easy to inspect.
Small inputs. Clear consequences.
Render once, but judge every ratio
Smart cropping needs a focal point or region that can survive multiple frames. A centered product pack shot may work across all three outputs. A model wearing a small accessory may not: the wide crop can retain the face and lose the product, while the portrait crop does the reverse. Treat each output as an acceptance decision, not merely another resize.
The worker interface below keeps the image implementation generic. It also makes the cache decision testable without putting a particular image library or hosted service at the center of the design. All code stays in TypeScript.
type Point = { x: number; y: number };
type ImageEngine = {
crop(input: {
sourceImageId: string;
focalPoint: Point;
width: number;
height: number;
quality: number;
format: "webp";
}): Promise<Uint8Array>;
};
type ObjectStore = {
exists(key: string): Promise<boolean>;
putImmutable(key: string, bytes: Uint8Array, contentType: string): Promise<void>;
};
async function renderMissing(
current: RenderManifest,
focalPoint: Point,
engine: ImageEngine,
store: ObjectStore,
): Promise<Record<Ratio, string>> {
const clean = stableManifest(current);
const digest = manifestDigest(clean);
const urls = {} as Record<Ratio, string>;
for (const ratio of ["og", "square", "portrait"] as const) {
const key = "cards/" + clean.productId + "/" + digest + "/" + ratio + ".webp";
urls[ratio] = "/" + key;
if (await store.exists(key)) continue;
const spec = clean.outputs[ratio];
const bytes = await engine.crop({
sourceImageId: clean.sourceImageId,
focalPoint,
width: spec.width,
height: spec.height,
quality: spec.quality,
format: clean.encoder,
});
await store.putImmutable(key, bytes, "image/webp");
}
return urls;
}
The existence check is an optimization, not the concurrency contract. Two workers can see a miss together. Give the immutable write create-only behavior, or make repeated writes of identical bytes harmless. Then retry the job when a render or upload fails, and publish the new metadata URL only after the required object exists. That ordering prevents HTML from advertising an image that has not arrived yet.
Misses should stay boring.
One caution: format support and image characteristics differ. MDN's image format guide summarizes browser support, compression behavior, transparency, animation, and common uses. Use that evidence when selecting output formats, then measure your own catalog images. Tiny labels, fabric texture, and hard-edged card text do not respond identically to compression.
Observe the decision, not just the worker
A render-duration chart can be green while the system wastes bandwidth. Instrument the causal decision. For each change event, record a low-cardinality result such as cache hit, rendered, rejected input, or render failed, plus the ratio and crop-policy version. Keep raw titles, product IDs, and full digests out of metric labels; put request-specific identifiers in structured logs where they can be searched without exploding metric series.
Three signals answer most operational questions:
- Render attempts by outcome and ratio show whether work is being skipped as intended.
- Output byte size by ratio reveals a quality-setting change that inflates transfer size.
- Time from accepted title change to metadata publication measures the user-visible pipeline, not one internal function.
Alert on symptoms readers experience: sustained publication delay or a meaningful run of failed renders. A single retry is context for a log, not automatically a page. Also log the old and new manifest digests with the fields that caused invalidation, but avoid logging the complete customer-facing title unless your data policy permits it.
The crisp before/after check belongs in deployment tests. Feed the same manifest twice and assert one set of object keys. Change only stock and assert the keys remain identical. Change the normalized title, source-image ID, quality, or crop-policy version one at a time and assert that each produces a new digest. Finally, request every produced object and decode it to verify dimensions.
What about titles that change back?
A title can move from A to B and later return to A. If every other pixel-affecting manifest field also returns to its previous value, the old digest returns too. Reusing those immutable bytes is correct. This is content reuse, not stale content.
Retention is a separate policy. Do not delete the prior object the moment metadata advances because existing posts may still reference its URL. Keep old objects for a retention window chosen from actual access logs and storage constraints, then remove only versions that are no longer referenced by page metadata and have aged past that window. No universal number fits every catalog.
There is one more objection: why not hash the final image bytes? That proves byte identity, but it cannot name the destination until after rendering. A manifest digest lets the system check for an existing object before doing image work. If byte-level verification matters, store a second checksum as object metadata after rendering. The two hashes answer different questions.
Ship the contract, then tune quality
Deploy the manifest and immutable-key behavior before adjusting compression. Start with a representative review set: light and dark products, dense labels, off-center subjects, transparent edges, and both short and long titles. Compare every ratio at its intended display size, and record output bytes beside the visual decision. The team can then raise quality where artifacts obscure product information and lower it where extra bytes buy no visible improvement.
The key idea remains small: regenerate on pixel-affecting input changes, not on generic product updates. A normalized title digest solves the original invalidation question only when the source image, crop policy, output geometry, encoder, and quality join the same contract. Make the URL immutable, make publication wait for the object, and observe the decision path. The cache becomes predictable.
Top comments (0)