How I Generated Dynamic OG Images with Astro and Cloudflare
After failing with @vercel/og and WASM compatibility issues on Cloudflare Workers, I found a solution using browser rendering and cron jobs. Here's the complete story.
After failing with @vercel/og and WASM compatibility issues on Cloudflare Workers, I found a solution using browser rendering and cron jobs. Here's the complete story.
You know that feeling when something that should be simple… just isn’t? That was me trying to generate Open Graph images for eventcompletion.com.
Each event on the platform needs its own unique OG image — with the event title, a progress bar showing how far along the event is (0-100%), a status indicator (upcoming, ongoing, or completed), and support for 25+ languages. Sounds straightforward, right?
Spoiler: It wasn’t.
In this post, I’ll share my journey of trying every “normal” way to generate OG images on Cloudflare Workers, running into WASM and Edge compatibility issues, and finally landing on a solution using Cloudflare’s browser rendering with an hourly cron job.
For eventcompletion.com, I needed OG images that:
The standard 1200x630 pixel OG size, but with dynamic content that changes as events progress.
First, I tried using @vercel/og with Astro. It’s the go-to solution for OG image generation, right? Well… not on Cloudflare Workers.
The issue? @vercel/og uses Resvg and Satori, which rely on WASM. And Cloudflare Workers… let’s just say they don’t play nice with all WASM modules, especially when you’re dealing with the Edge runtime and Node.js compatibility layers.
I tried adding nodejs_compat to my wrangler.toml:
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
This helped with some Node.js APIs, but the WASM issues persisted. The browser APIs that some image generation libraries depend on just aren’t available in the Workers runtime.
I tested multiple packages — different Satori implementations, alternative canvas libraries, you name it. Each one hit the same wall: WASM compilation errors or missing browser APIs when running on Cloudflare’s Edge runtime.
The core issue: Cloudflare Workers run in a restricted environment that doesn’t support everything a typical Node.js server can do. Image generation libraries often depend on native modules or browser APIs that aren’t available.
After banging my head against the wall, I discovered Cloudflare’s Browser Rendering service. It’s essentially Puppeteer running on Cloudflare’s infrastructure — and it actually works!
Instead of trying to render images directly with WASM-heavy libraries, I:
// src/pages/api/og/[slug].ts
export async function GET({ params }) {
const { slug } = params;
// Check R2 cache first
const cached = await env.IMAGES.get(`og-images/${slug}.png`);
if (cached) {
return new Response(cached.body, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400' // 24 hours
}
});
}
// Generate on-demand if not cached
const image = await generateOGImage(event, env);
await env.IMAGES.put(`og-images/${slug}.png`, image);
return new Response(image, {
headers: { 'Content-Type': 'image/png' }
});
}
Instead of generating images on every request (which would be slow), I use an hourly cron job to pre-generate images for active events:
# wrangler.toml
[triggers]
crons = ["0 * * * *"] # Every hour at minute 0
The cron job is smart about what it generates:
// src/pages/api/og/generate-all.ts
const now = Date.now();
const startDate = new Date(event.start_date).getTime();
const endDate = new Date(event.end_date).getTime();
// Skip upcoming events (0% progress - nothing to show yet)
if (now < startDate) return;
// Skip events completed more than 3 hours ago (no longer relevant)
if (now > endDate + 3 * 60 * 60 * 1000) return;
// Only generate images for active/current events
generateAndStore(event);
This means the cron job only processes events that are actually happening or recently completed — saving processing time and storage.
Here’s the core logic for calculating progress and status:
// src/utils/og-generator.ts
const now = Date.now();
const startDate = new Date(event.start_date).getTime();
const endDate = new Date(event.end_date).getTime();
let progress: number;
let status: 'upcoming' | 'ongoing' | 'completed';
if (now < startDate) {
progress = 0;
status = 'upcoming';
} else if (now > endDate) {
progress = 100;
status = 'completed';
} else {
// Calculate progress percentage
progress = Math.min(
Math.max(((now - startDate) / (endDate - startDate)) * 100, 0),
100
);
status = 'ongoing';
}
Then Puppeteer renders the HTML template with these values:
const browser = await env.BROWSER.connect();
const page = await browser.newPage();
await page.setContent(template({ event, progress, status }));
const screenshot = await page.screenshot({
type: 'png',
clip: { x: 0, y: 0, width: 1200, height: 630 }
});
await browser.disconnect();
The template is just regular HTML/CSS, which is great because:
<div class="og-image" style="width: 1200px; height: 630px; ...">
<div class="header">
<h1>{{ event.name }}</h1>
<span class="status">{{ statusLabel }}</span>
</div>
<div class="progress-container">
<div class="progress-bar" style="width: {{ progress }}%"></div>
</div>
<div class="footer">
<img src="{{ event.banner }}" alt="Event banner" />
</div>
</div>
By using Cloudflare’s browser rendering, I avoid all the WASM and Edge compatibility headaches. Puppeteer on Cloudflare is a first-class service — it just works.
Cloudflare’s infrastructure handles the traffic spikes. When an event goes viral and thousands of people share the link, the CDN serves the cached images without breaking a sweat.
Because I’m rendering real HTML, I can:
If you want to implement something similar, here’s what you need:
npm install @cloudflare/puppeteer
# wrangler.toml
[browser]
binding = "BROWSER"
[[r2_buckets]]
binding = "IMAGES"
bucket_name = "your-bucket-name"
[triggers]
crons = ["0 * * * *"] # Hourly
// Environment variables in Cloudflare dashboard
// CRON_SECRET — to secure your cron endpoint
“Standard” solutions don’t always work on Edge — What works on Vercel or a traditional server might not work on Cloudflare Workers, and that’s okay.
Use the platform’s strengths — Cloudflare Browser Rendering is designed for exactly this use case. Sometimes the platform-specific solution is better than the “universal” one.
Cron + Cache = Performance — Generating images ahead of time and caching them is way more efficient than generating on-demand.
Have a fallback — On-demand generation as a fallback means your site never breaks, even if the cron job fails.
Generating dynamic OG images on Cloudflare Workers was harder than I expected, but the solution I ended up with is more robust and scalable than the “easy” paths I tried initially.
If you’re struggling with OG image generation on Cloudflare (or any Edge platform), give browser rendering a shot. It might feel like “overkill” to spin up a browser just to take a screenshot, but for dynamic content, it’s hard to beat.
And hey — at least you won’t have to debug WASM compilation errors at 2 AM.
Want to see the final result? Check out eventcompletion.com and share an event on social media to see the OG images in action.
Questions? Feel free to reach out — let’s talk about OG images, Cloudflare Workers, or your own Edge runtime struggles.
Hello! I'm Stan Leon, a developer and designer based in France. This is the beginning of my journey sharing thoughts on code, design, and creative explorations.
Sharing knowledge and connecting with others through writing has always been important. Here's why I decided to start this blog.