Dynamic OG Images at Scale

Dynamic OG Images at Scale
Photo by Bank Phrom / Unsplash

What? "Open Graph is used to represent a webpage when it's shared on social media platforms like Facebook, Twitter, and LinkedIn"

if you ask me it's the correct way to share links.

naked links like https://www.youtube.com/watch?v=L45Q1_psDqk suck, are inhumane, and ugly.

That same link with an Open Graph image becomes a visual treat 😍, delivers more information, triggers the visual cortex 🧠, and most importantly increases open rate!📈

“Every time you share a plain link, a click dies somewhere.”

In this blog we will be creating a system to handle creation of dynamic opengraph images, caching strategy, templating, with telemetry while fasting on resources.

What's for dinner?

Whenever someone shares the link to a news article on platforms like WhatsApp or LinkedIn, we cook up a custom OG image like the one below.

The image features:

  • The article headline as the main text
  • The article image as the background
  • A subtle black gradient at the bottom to enhance readability

It’s clean, readable, and tailored — every time.

System Design

Each class in our system strictly follows the Single Responsibility Principle.

  1. OGImageController - exposes our API routes and handles incoming requests
  2. OGImageService - the core engine that generates OG images
  3. ResourceManager - handles static assets like fonts, logos, and images
  4. TemplateFactory - creates and manages image templates dynamically
  5. CacheService - checks, stores, and retrieves cached OG images

🔄 Flow Overview

When someone shares a link (e.g., on WhatsApp), the platform sends a GET request to our API (OGImageController). Here’s what happens next:

  1. OGImageController checks if we’ve already generated the image.
  2. If cached, CacheService returns the path instantly.
  3. If not, OGImageService creates a fresh OG image using TemplateFactory.
  4. The image is saved and cached for future requests.
  5. The .webp file is served as a response in real-time.
Since the controller is the entry point for all image generation requests, let’s start by designing the OGImageController.

🧑‍💻 OGImageController

This class acts as the interface between our FastAPI server and the image generation system

Initialization

def __init__(self, 
             og_image_service: Optional[OGImageService] = None,
             resource_manager: Optional[ResourceManager] = None):
We allow optional dependency injection of the image service and resource manager — useful for testing or extending.

If not provided, defaults are created:

self.resource_manager = resource_manager or ResourceManager()
self.og_image_service = og_image_service or OGImageService(
    resource_manager=self.resource_manager,
    template_factory=TemplateFactory(self.resource_manager),
    cache_service=CacheService()
)
This bootstraps the entire image generation pipeline using ResourceManager, TemplateFactory, and CacheService.

We also define a FastAPI router:

self.router = APIRouter(prefix="/api/v1/og", tags=["OG Images"])
self._register_routes()
Every OG-related route is grouped under /api/v1/og.

The News OG Endpoint

@self.router.get("/news/{slug}")
async def get_news_og(slug: str, background_tasks: BackgroundTasks):
This defines a GET endpoint to serve OG images for news articles. It takes a slug from the URL and handles image generation asynchronously.

Tracing ( Not Compulsory)

with tracer.start_as_current_span("get_news_og") as span:
    span.set_attribute("news_slug", slug)
We instrument the request using OpenTelemetry. Every image generation request is traceable — helpful for debugging and observability.

Data Fetching

news_data = await db.get_news_by_slug(slug)
if not news_data:
    raise HTTPException(status_code=404, detail="News not found")
The controller queries the database. If the article doesn’t exist, it returns a 404.

Triggering OG Image Generation ❗️

image_path = await self.og_image_service.generate_image(
    TemplateType.NEWS, news_data
)
This is where the controller hands over responsibility to the OGImageService, which renders the image.

Response and Cleanup

background_tasks.add_task(self._cleanup_temp_file, image_path)
return FileResponse(image_path, media_type="image/webp")
We serve the .webp image and schedule the temporary file for deletion after the response is sent.

Temporary File Cleanup

async def _cleanup_temp_file(self, path: str):
    os.unlink(path)
This background task ensures your disk doesn’t fill up with stale image files.

🫀 The Heart of the System

Once the controller receives a request and fetches the data, it hands off the heavy lifting to OGImageService.

This class is responsible for creating the image from data, caching it, and returning the final file path — all while staying decoupled from HTTP logic. It’s where rendering and template logic actually lives.

Initialization

def __init__(self, resource_manager, template_factory=None, cache_service=None):
This constructor allows injecting or falling back to default services:
  • ResourceManager: loads fonts, logos, and other assets
  • TemplateFactory: chooses the correct image template class based on type
  • CacheService: handles disk (or other strategy) caching of generated images

The generate_image() Method

This is the core method that does all the work:

Step 1: Cache Key + Cache Check
cache_key = self._create_cache_key(template_type, data)
if not force_regenerate:
    cached_path = await self.cache_service.get_cached_image(cache_key)
    if cached_path:
        return cached_path
If the image is already cached, we short-circuit the process and return it instantly. This avoids regenerating the same OG image repeatedly.
Step 2: Template Creation + Rendering
template = self.template_factory.create_template(template_type, data)
template.render(data)
A dynamic template (e.g. news) is created and populated with data — this is where the title, and background come together.
Step 3: Output Path + Save
output_path = template.get_output_path(data)
template.save(output_path, format=ImageFormat.WEBP, quality=WEBP_QUALITY)
The rendered image is saved as a .webp file with the given quality settings. Output path is derived based on the slug or ID.
Step 4: Cache and Cleanup
await self.cache_service.save_to_cache(output_path, cache_key)
del template
gc.collect()
The saved image is cached for future requests and memory is cleaned up.
Cache Key Generator
def _create_cache_key(self, template_type, data)
Generates a unique key based on the template type and identifying info (e.g. slug or post ID). This ensures correct image reuse per entity. example news_auto_sector_chine

🖼️ Picking the Right Style

Once the OGImageService is asked to generate an image, it doesn't know how to design it — that's the job of a template. This is where TemplateFactory steps in.

It acts as a router for our template logic, returning the correct image layout based on the TemplateType. This keeps the rendering logic modular and extensible.

🔍 Example: News Article Template

Let’s say a link to a news article is shared. The controller fetches article data, and the service passes this to the factory with TemplateType.NEWS.

Here’s what happens inside:

if template_type == TemplateType.NEWS:
    return NewsTemplate(self.resource_manager)
This returns an instance of the NewsTemplate, which knows exactly how to:Load the article’s cover imageDraw a black gradient at the bottomOverlay the headline in large white textApply consistent margins, padding, and font styles

All of this is possible because it inherits from a shared ImageTemplate base class, and uses assets loaded by the ResourceManager.


Awesome — the NewsTemplate class is visually rich and highly customizable. This is where the actual look of your news OG image is defined. Here's how you can structure this section in your blog.


To get the most out of this article, keep the GitHub repo open in a separate tab or side-by-side.

📰 Designing the News Card

Once TemplateFactory hands off control to a NewsTemplate, this class handles the entire layout and styling of the OG image — from the background to the headline text.

We don’t use a static image — we build it programmatically from scratch using Pillow.

🎨 Step-by-Step: How the Template Works

1. Creates a Blank Canvas

self.image = Image.fromarray(np.zeros((self.height, self.width, 3), dtype=np.uint8))
The image starts off completely black, matching the intended dimensions (e.g. 1200×630). This is useful when there’s no base template file — we draw everything from scratch.

2. Downloads and Processes the Background Image

bg_img = self._process_background_image(image_url)
self.image.paste(bg_img, (0, 0))
A background image (usually from a news post's imageUrl) is downloaded, resized using object-fit: cover logic, and pasted into the canvas.

3. Adds a Black Gradient for Readability

gradient = ImageProcessor.create_gradient(...)
self.image.paste(gradient, (0, img_height - fade_height), gradient)
A vertical black-to-transparent gradient is added to the bottom 40% of the image. This ensures white text is readable on top of any background.

4. Draws the Headline Title (with Wrapping)

wrapped_title = ImageProcessor.wrap_text(...)
self.draw.text(...)
The title is wrapped and center-aligned in the lower section of the image — inside the black gradient. This prevents long headlines from overflowing.
5. (Optional) Draws Brand Tag
self.draw.text(..., brand, ...)
If a brand value exists (e.g., “Medial”), it’s drawn in a small corner of the card — like a watermark or publisher stamp.
🧪 Example Input Data
{
  "title": "Budget 2025 Announced: Key Reforms in AI, Energy & Infra",
  "imageUrl": "https://cdn.medial.app/articles/budget-bg.jpg",
  "brand": "Medial",
  "slug": "budget-2025"
}

Excellent. The ResourceManager is a critical utility class in your OG image pipeline — it's what makes everything fast, memory-efficient, and reusable. Here's how to structure the next section of your blog:


📦 Asset Loading, the Smart Way

Rendering high-quality images on demand requires loading a lot of assets — fonts, base templates, overlays — and doing it fast.

ResourceManager is the behind-the-scenes hero that:

  • Efficiently loads fonts and templates
  • Caches them intelligently in memory
  • Periodically cleans up unused assets to save memory

🔧 How It Works

1. Caches Templates and Fonts In-Memory
self._templates: Dict[str, Tuple[Image.Image, float]] = {}
self._fonts: Dict[str, Tuple[ImageFont.FreeTypeFont, float]] = {}
Both fonts and templates are cached in dictionaries, keyed by a unique identifier. Each entry also stores a timestamp so we can expire unused items. mimics soft LRU cache.

2. Auto-Cleans Up Old Entries
self._cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True)
A background thread runs every 30 seconds to clean up expired resources from memory. This avoids memory bloat on long-running servers.

3. Prevents Disk Reads on Every Request
if cache_key in self._fonts:
    return self._fonts[cache_key][0]
If the requested font/template is already in memory, we reuse it instead of reading from disk again. This significantly improves response time and lowers IO load.

🔍 Example: How Fonts Are Loaded
absolute_font_path = self._assets_dir / font_path
font = ImageFont.truetype(str(absolute_font_path), size)
Fonts are loaded from a well-defined assets directory, with fallback error handling if a font is missing.

🧼 Trim + Expiry Logic

If the cache exceeds a defined limit, the oldest item is evicted:

self._trim_cache(self._fonts)

Resources that haven't been accessed recently are also expired by the cleanup thread using timestamp checks.

💡 Developer Tip

If you're testing locally and want to ensure no old asset stays in memory:

resource_manager.clear_caches()

This flushes all caches instantly and can help debug template/font load issues.


Here’s a clean breakdown of your CacheService class for your blog, written in a blog-friendly and dev-readable style. You can place this after ResourceManager.


💾 Efficient Storage with CacheService

After the OG image is rendered, we don’t want to recreate it every time someone shares the same link. That’s where the CacheService comes in.

It stores and retrieves generated images so that future requests are instant. It supports both disk-based caching (local) and cloud-based caching (S3).


⚙️ Key Features

Capability How it's Handled
Cache Strategy (None/Disk/S3) Controlled by an enum: CacheStrategy
Disk-based cache Images are saved in a local generated/ dir
S3-based cache Uploads files to an AWS S3 bucket
Cache expiration Disk cache checks file age and TTL
Observability Every call is traced with OpenTelemetry

🔄 How It Works (Flow)

1. On Request: Check Cache
await cache_service.get_cached_image("news_budget-2025")
Based on the configured strategy, it checks:
  • Disk: whether generated/news_budget-2025.webp exists and is fresh
  • S3: whether the object exists in the bucket
2. On Cache Miss: Save Image
await cache_service.save_to_cache(image_path, key)
  • If strategy is DISK, it does nothing — the image was already written locally.
  • If strategy is S3, it uploads the image to the bucket with a long-lived cache control header (max-age=31536000 personal preference) .

🧠 Disk vs S3 Caching
Strategy Use Case Example
DISK Local dev, Docker containers, small scale deployments
S3 Production, distributed infra, CDN delivery via S3 + CloudFront
🧼 Disk Expiration Logic
file_age = time.time() - os.path.getmtime(cache_path)
if file_age < self.cache_timeout:
    return str(cache_path)
Images older than the defined CACHE_TIMEOUT are considered expired and ignored.
🌐 S3 Upload with Caching Headers
self.s3_client.upload_file(..., ExtraArgs={'CacheControl': 'max-age=31536000'})
Tells browsers/CDNs to cache the image for a year — perfect for public, static OG images.
💡 Use CloudFront in front of your S3 bucket to serve cached OG images globally with low latency. Set proper cache-control headers to maximize CDN efficiency.

🏁 Wrapping Up

Dynamic OG image generation isn’t just a nice-to-have — it’s a game-changer for how your content appears on the internet. With this architecture, we’ve built a system that’s fast, modular, cache-friendly, and production-ready — from controller to rendering, asset management to global CDN caching. Whether you’re powering a blog, a news portal, or a social app, investing in rich, real-time visual previews will elevate your brand and boost engagement — one share at a time.


👨‍💻 About me

This post was written by Ayush Tripathi, a software developer and platform engineer at Medial.app, who loves building performant systems and developer-first infrastructure.


🧠 Code on GitHub

You can check out the full implementation on GitHub here:
🔗 https://github.com/Ayush-pbh/dynamic-og-service

It has a few bonuses like, otel counters, and slack notifications. do check out.

Feel free to fork it, try it locally, and plug it into your own project. PRs and ideas are always welcome.