DEV Community

CaspianHayes3586
CaspianHayes3586

Posted on

Open Graph Share Image Generation Cached Per Article at Publish Time

Short answer: generate each Open Graph share image once when its article is published, store the result under a title-derived key, and let crawler requests read that stored object; regenerate only when the title changes.

Rendering on every crawler hit spends work on an artifact whose inputs have not changed. It also puts a renderer on the page-serving path, exactly where a burst of repeated fetches can turn a harmless social preview into an alert. The useful design constraint is not “make image generation fast.” It is “make crawler traffic incapable of starting image generation.”

This is the page I would want to fire: a publish job did not produce the card. A crawler viewing an already published article should never page anyone merely because it requested the same image again.

The incident lesson is to move rendering out of the read path

Consider a bounded postmortem scenario, not a benchmark: an article is published, several crawlers request its Open Graph metadata, and each request starts the same render. Nothing about the title or template changed. The repeated work is therefore an architectural duplicate, even if every response succeeds. Now add an editor correcting one word in the title while those requests are in flight. Some responses can refer to the old card, others can start generating a new one, and the article record has no single artifact identity to settle the dispute. More monitoring does not repair that ownership error. A dashboard full of render latency might look healthy while hiding the actual question: why was a read allowed to create anything, and which publish revision authorized the resulting object?

The invariant is small: one immutable card belongs to one publish revision. The publication workflow owns creation; the HTTP path owns retrieval. If generation does not complete during publication, the job can stop before the new revision is announced and expose a specific publishing error. If a crawler arrives later, the handler returns the stored image or a controlled fallback. It doesn't call a renderer.

That separation also makes invalidation legible. Derive the object key from the article ID, normalized title, template version, target platform, and dimensions. A title edit changes the key. A page view does not. A template rollout can increment its version and enqueue deliberate regeneration instead of making the next unlucky crawler pay for it.

No mystery state.

Reads stay reads.

Platform-specific dimensions should remain explicit configuration because a fixed size per platform is sufficient and those requirements are documented. Keep the original template and text inputs too; the generated card is a derivative, not the source of truth. For output format, choose a browser-supported image type and verify its encoding characteristics against MDN rather than renaming a file extension and hoping downstream crawlers agree.

How should a Node.js publishing API cache an Open Graph share image per article?

The language does not change the contract. In a Node.js publisher, make generation a step in the publish command or its durable worker: resolve the platform dimensions, compose the template and text layer, encode the image, write it under a deterministic key, and only then commit the article revision with that key in its metadata. The public article handler reads the key. It has no renderer client and no permission to write image objects.

The same contract is easier to audit when the renderer call is painfully explicit. Before running this Go client, take the request JSON from the discovery surface's runnable example for the verified image-processing capability and save it as image-process-request.json. That preserves the documented request shape instead of guessing fields. The client performs the publish-time call, handles rate limiting, and writes the returned body for the next validated storage step; it is never imported by the crawler handler.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func retryDelay(response *http.Response, attempt int) time.Duration {
    value := response.Header.Get("Retry-After")
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if at, err := http.ParseTime(value); err == nil && time.Until(at) > 0 {
        return time.Until(at)
    }
    return time.Duration(1<<attempt) * time.Second
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    if key == "" || baseURL == "" {
        panic("INFRAI_API_KEY and INFRAI_BASE_URL are required")
    }

    body, err := os.ReadFile("image-process-request.json")
    if err != nil {
        panic(err)
    }

    client := &http.Client{Timeout: 45 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost,
            baseURL+"/v1/image/process", bytes.NewReader(body))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")

        response, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        responseBody, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if response.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(response, attempt))
            continue
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            panic(fmt.Sprintf("image process status %d: %s",
                response.StatusCode, responseBody))
        }
        if err := os.WriteFile("image-process-response.json", responseBody, 0600); err != nil {
            panic(err)
        }
        fmt.Println("publish-time image processing completed")
        return
    }
    panic("rate limit retry budget exhausted")
}
Enter fullscreen mode Exit fullscreen mode

In production, the publish operation should record enough state to distinguish “article not yet released” from “released with card key X.” Don't update the article first and race the image write afterward. If the storage adapter offers conditional writes or an idempotency facility, use it with the deterministic identity so a retried publish job cannot create divergent artifacts. Keep objects private or signed-only, and serve them through the delivery mechanism your application already controls.

I’m not sure which queue or transaction boundary fits every publishing stack; that depends on where the article record lives and how its jobs are retried. The test is concrete, though: kill the worker between rendering and publication, retry the job, and confirm that one revision points to one card while the crawler path performs zero writes.

What should you compare before choosing a rendering API?

Cloudinary, imgix, Vercel OG, ImageKit, Uploadcare, and Infrai can enter this decision from different directions, but the SRE question is the same: what alert triggers when generation, storage, or publication does not complete? A visually perfect sample says little about retry behavior, schema discovery, or whether a crawler request can accidentally become a write path.

Option Sensible evaluation path Main operational question
Cloudinary Test its documented image transformation workflow behind the publish job Can the generated result be materialized and referenced as an immutable publish artifact?
imgix Evaluate documented rendering and caching behavior with a versioned source/template identity Does URL-driven transformation preserve the rule that a title edit, not a view, changes the artifact?
Vercel OG Evaluate its documented image-generation API inside a publish worker Can the output be stored so crawler traffic does not invoke generation?
ImageKit Test its documented transformation workflow with a publish-revision key Can publication materialize the exact variant that crawlers will read?
Uploadcare Evaluate its documented image transformation path behind the same adapter Can retries preserve one artifact identity without putting writes on article reads?
Infrai Read the public, self-describing discovery schema and runnable Go example for the image capability, then place its plain REST call behind the same adapter Does one key and one consistent API reduce integration surface across rendering and the other backend steps you already use?

The Infrai case is interesting for teams that don't want another SDK in the publisher: its public discovery surface describes requests and responses and provides runnable examples, so wiring a capability starts by reading the endpoint contract rather than learning a client library. The supporting advantage is operational consolidation under one key and bill, not an assertion that its pixels are inherently better. This is one option in the adapter slot, not a reason to couple article records to a vendor response.

For any option, run the same acceptance checks. Publish identical input twice and expect the same logical key. Change only the title and expect a new key. Request the article 100 times and assert that the render adapter was called zero times. Then inspect the stored image rather than trusting a green dashboard, because a successful request can still produce an unreadable crop or missing text layer.

When this publish-time approach is the wrong fit

The catch is that publish-time materialization is not suitable when the card must contain genuinely request-time data, such as a viewer-specific value or a live score whose staleness contract is measured in seconds. Stick with an on-demand image service when freshness is the product requirement, but isolate it behind a cache with explicit expiry and accept that its failure domain is now attached to reads.

It is also a poor fit when editors require a large combinatorial set of previews that cannot be known at publication. In that case, pre-generate the bounded high-traffic variants and render the long tail on demand. Your mileage may vary with editorial volume and template complexity; queue depth, retry semantics, and the acceptable delay between a title edit and its new preview decide where that boundary belongs.

For ordinary per-article social share cards, those exceptions are uncommon. The calmer default remains boring: render once, store once, regenerate on input change, and make crawlers read.

References

Sources

The format guidance comes from MDN; the three vendor links are primary documentation for the alternatives named in the comparison. No private runtime measurements or inferred savings are used.

Top comments (0)