Dynamic OG Images at Scale
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.


open graph images example in messages and x.com
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.
OGImageController
- exposes our API routes and handles incoming requestsOGImageService
- the core engine that generates OG imagesResourceManager
- handles static assets like fonts, logos, and imagesTemplateFactory
- creates and manages image templates dynamicallyCacheService
- 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:
OGImageController
checks if we’ve already generated the image.- If cached,
CacheService
returns the path instantly. - If not,
OGImageService
creates a fresh OG image usingTemplateFactory
. - The image is saved and cached for future requests.
- 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 usingResourceManager
,TemplateFactory
, andCacheService
.
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 assetsTemplateFactory
: chooses the correct image template class based on typeCacheService
: 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'simageUrl
) is downloaded, resized usingobject-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.