We build email campaigns for clients. One thing that consistently drives clicks is urgency — countdown timers showing "sale ends in 4 hours" or "registration closes tomorrow."
The problem? Email clients don't execute JavaScript. No scripts, no dynamic content, no interactivity. You get HTML, CSS (partially), and images. That's it.
So how do you show a live, ticking countdown in an email? You render it on the server. Every single time.
The Core Concept
When an email client opens a message, it requests all embedded images from their source URLs. This is the same mechanism that powers tracking pixels — a tiny 1x1 image that tells the sender "this email was opened."
We use the same principle, but instead of a tracking pixel, we serve a full countdown timer image. The key insight: the image is not cached. Every time the email is opened, the client requests the image again, and our server generates a fresh one with the current remaining time.
Recipient opens email
→ Email client requests image from our server
→ Server calculates: campaign_end_time - current_time = remaining
→ Server renders image with remaining time
→ Email client displays fresh countdown
Open the email now, you see "4 hours 22 minutes left." Open it again in an hour, you see "3 hours 22 minutes left." It's always accurate.
The Technical Implementation
Image Generation
We use PHP with the GD library for image rendering. No external services, no headless browsers, no Puppeteer. Pure server-side image generation.
The rendering pipeline:
- Request comes in with a campaign identifier
- We fetch the campaign config from cache (Redis) — end time, colors, fonts, dimensions
- Calculate remaining time:
end_time - now() - Render the image frame by frame
- Return the image with appropriate headers
// Simplified version of the core logic
public function renderTimer(string $campaignId): Response
{
$campaign = $this->cache->get("campaign:{$campaignId}");
$remaining = $campaign->end_time->diffInSeconds(now());
if ($remaining <= 0) {
return $this->renderExpiredImage($campaign);
}
$hours = floor($remaining / 3600);
$minutes = floor(($remaining % 3600) / 60);
$seconds = $remaining % 60;
$image = $this->generateImage(
width: $campaign->width,
height: $campaign->height,
text: sprintf('%02d:%02d:%02d', $hours, $minutes, $seconds),
backgroundColor: $campaign->bg_color,
textColor: $campaign->text_color,
fontFamily: $campaign->font,
fontSize: $campaign->font_size,
);
return response($image)
->header('Content-Type', 'image/gif')
->header('Cache-Control', 'no-store, no-cache, must-revalidate')
->header('Pragma', 'no-cache')
->header('Expires', '0');
}
Cache Headers Are Everything
This is the most critical part. If the email client caches the image, the timer shows stale time on subsequent opens. We need to prevent caching aggressively:
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Expires: 0
Most email clients respect these headers. Some (looking at you, certain versions of Outlook) have their own caching behavior that you can't fully control. In practice, the majority of major email clients — Gmail, Apple Mail, Yahoo, Outlook web — will re-request the image on each open.
Why GIF and Not PNG or JPEG?
We serve GIF format for a few reasons:
- Universal email client support — GIF works everywhere
- Small file size for simple graphics like text and numbers
- Ability to add simple animation (optional — some timers use animated GIF frames to show the seconds ticking)
- Transparency support if needed for different email backgrounds
For animated timers, we generate multiple GIF frames showing the countdown progressing second by second. The animation loops, giving the illusion of a ticking clock even without a server re-request. But the real accuracy comes from the server-side recalculation on each open — the animation is just visual polish.
The Impression Pipeline
Every image request is also a data point. We log impressions asynchronously to avoid slowing down image delivery:
// Fire and forget — don't block the image response
dispatch(new LogImpression(
campaignId: $campaignId,
openedAt: now(),
userAgent: $request->userAgent(),
ip: $request->ip(),
));
We use a database-backed queue (Laravel's queue system) to process these. At scale, this means the image response is fast (just render and return) while the analytics processing happens in the background.
Redis Caching Strategy
Campaign configs rarely change, but they're requested on every email open. We cache them aggressively in Redis:
// Cache campaign config for 1 hour
// Invalidate on any campaign update
$campaign = Cache::remember(
"campaign:{$id}",
3600,
fn() => Campaign::findOrFail($id)
);
The image itself is never cached — that's the whole point. But everything needed to generate the image (colors, fonts, end time, dimensions) is cached so we're not hitting the database on every request.
Edge Cases We Had to Handle
Timer expired
What happens when the countdown hits zero? You can't show "00:00:00" forever. We render a configurable "expired" state — either a custom message ("Sale ended"), a different image, or just hiding the timer entirely by returning a 1x1 transparent pixel.
Clock skew
Our server's clock must be accurate. A 30-second drift means every timer is 30 seconds off. We use NTP synchronization and monitor for drift.
Time zones
Campaign end times are stored in UTC. The timer always shows remaining time, not a clock — so time zones don't matter. "3 hours left" is "3 hours left" regardless of where the recipient is.
Email forwarding
If someone forwards the email, the new recipient sees a fresh countdown based on when they open it. This is actually a feature, not a bug — the timer is always relevant to the current viewer.
High traffic spikes
A single email campaign can send 50,000 emails. If even 20% open within the first hour, that's 10,000 image generation requests in 60 minutes. Our approach:
- Image generation is CPU-bound, not I/O-bound — GD is fast for simple graphics
- Redis eliminates database queries for campaign configs
- Queue workers handle impression logging without blocking
- Horizontal scaling is straightforward — any server with the campaign cache can render the image
What Doesn't Work
A few things we tried and abandoned:
SVG in emails — Some email clients support inline SVG, which would allow CSS animations for a ticking effect. In practice, support is too inconsistent. Gmail strips SVG. Outlook ignores it. Not worth the compatibility headaches.
CSS animation timers — Clever CSS-only countdown techniques exist, but email CSS support is a minefield. What works in a browser rarely works in an email client.
JavaScript-based fallbacks — AMP for Email supports JavaScript, but adoption is minimal and most email clients don't support it. We stick to what works everywhere: images.
The Result
The whole system serves a single purpose: make a <img> tag in an email show accurate, live remaining time. The embed code is simple:
<img src="https://mailfomo.com/timer/{campaign_id}"
alt="Time remaining"
width="600"
height="200" />
Paste that into any email platform — Mailchimp, Klaviyo, SendGrid, Shopify, WooCommerce, whatever supports HTML in emails. The email platform doesn't need to know or care what the image contains. It just loads an image from a URL. Our server does the rest.
The Stack
- Backend: Laravel (PHP)
- Image rendering: GD library
- Caching: Redis
- Queue: Database-backed (Laravel Queues)
- Server: Hetzner Cloud + Caddy
- Monitoring: Custom impression tracking
We built this as part of Mailfomo — if you want to try it out, there's a free tier. We launched today and would love any feedback, especially on the technical approach.
If you're dealing with similar problems (dynamic content in static contexts), I'm happy to discuss approaches in the comments.
Tags: php, laravel, email, webdev
Top comments (0)