Every time you render a React Email template, you're running JSX → HTML conversion, inlining CSS, and potentially hitting your database for brand colors or user data.
For a password reset? That's fine. For 10,000 welcome emails in an hour? You need caching.
Why caching matters for email templates
React Email's render() function is synchronous and relatively fast, but it's not free:
- JSX evaluation + React reconciliation
- CSS inlining (the slowest part)
- HTML minification
- String serialization
Multiply that by thousands of emails per hour and you're burning CPU, delaying sends, and potentially hitting serverless function timeouts.
console.time()before implementing caching. If renders are under 20ms, caching might not be your bottleneck.5 caching strategies (and when to use each)
1) Static pre-rendering for templates with no dynamic data
If your template uses zero props (or only props that change once per deploy), pre-render it at build time.
import { render } from '@react-email/render';
import { StaticAnnouncementEmail } from '@/emails/static-announcement';
// Pre-render at module load (happens once per cold start)
const STATIC_ANNOUNCEMENT_HTML = render(<StaticAnnouncementEmail />);
export async function sendAnnouncement(to: string) {
await resend.emails.send({
from: 'team@example.com',
to,
subject: 'Product update',
html: STATIC_ANNOUNCEMENT_HTML, // ← Cached HTML
});
}2) Memoized partial rendering for shared layouts
Most emails share a layout (header, footer, brand colors). Cache the layout separately from the dynamic content.
import { Html, Head, Body, Container } from '@react-email/components';
import { cache } from 'react';
// Cached layout factory
export const getCachedLayout = cache((brandColors: BrandColors) => {
const layoutStyles = {
header: { backgroundColor: brandColors.primary },
footer: { color: brandColors.text },
};
return {
Header: () => <Header style={layoutStyles.header} />,
Footer: () => <Footer style={layoutStyles.footer} />,
};
});
// Usage in template
export function WelcomeEmail({ userName, brandColors }: Props) {
const { Header, Footer } = getCachedLayout(brandColors);
return (
<Html>
<Header />
<Body>
<Container>
<p>Welcome, {userName}!</p>
</Container>
</Body>
<Footer />
</Html>
);
}3) Redis caching for high-volume, low-variation templates
For templates with a small set of variations (e.g., verification emails with different locales), cache rendered HTML in Redis with a TTL.
import { redis } from '@/lib/redis';
import { render } from '@react-email/render';
import { VerificationEmail } from '@/emails/verification';
async function getCachedVerificationEmail(locale: string): Promise<string> {
const cacheKey = `email:verification:${locale}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached) return cached;
// Render and cache for 1 hour
const html = render(<VerificationEmail locale={locale} />);
await redis.setex(cacheKey, 3600, html);
return html;
}
export async function sendVerificationEmail({
to,
verificationUrl,
locale = 'en',
}: {
to: string;
verificationUrl: string;
locale?: string;
}) {
// Get cached template HTML
const templateHtml = await getCachedVerificationEmail(locale);
// Replace dynamic token (faster than full re-render)
const finalHtml = templateHtml.replace(
'{{VERIFICATION_URL}}',
verificationUrl
);
await resend.emails.send({
from: 'verify@example.com',
to,
subject: 'Verify your email',
html: finalHtml,
});
}4) In-memory LRU cache for frequently-used variants
For serverless or single-instance deployments, an in-memory LRU cache can eliminate Redis round-trips for hot templates.
import { LRUCache } from 'lru-cache';
import { render } from '@react-email/render';
// Cache up to 100 rendered templates, expire after 5 minutes
const templateCache = new LRUCache<string, string>({
max: 100,
ttl: 1000 * 60 * 5,
});
export function renderCached<T extends React.ComponentType<any>>(
Component: T,
props: React.ComponentProps<T>,
cacheKey: string
): string {
const cached = templateCache.get(cacheKey);
if (cached) return cached;
const html = render(<Component {...props} />);
templateCache.set(cacheKey, html);
return html;
}
// Usage
const html = renderCached(
WelcomeEmail,
{ userName: 'Alice', locale: 'en' },
`welcome:en` // Cache key (no user-specific data)
);5) Background worker pre-generation for scheduled sends
For large batch sends (weekly digests, campaign emails), pre-render all variants in a background job before the send window.
import { render } from '@react-email/render';
import { queue } from '@/lib/queue';
import { WeeklyDigestEmail } from '@/emails/digest';
// Cron job: runs 10 minutes before send window
export async function prerenderDigests() {
const recipients = await db.user.findMany({
where: { subscribed: true },
select: { id: true, locale: true, preferences: true },
});
// Group by variant to deduplicate renders
const variantGroups = new Map<string, typeof recipients>();
for (const user of recipients) {
const variantKey = `${user.locale}:${user.preferences.theme}`;
if (!variantGroups.has(variantKey)) {
variantGroups.set(variantKey, []);
}
variantGroups.get(variantKey)!.push(user);
}
// Render each variant once, then queue sends
for (const [variantKey, users] of variantGroups) {
const [locale, theme] = variantKey.split(':');
const html = render(
<WeeklyDigestEmail locale={locale} theme={theme} />
);
// Cache rendered HTML
await redis.setex(`digest:${variantKey}`, 3600, html);
// Queue individual sends (just merge user data + cached HTML)
for (const user of users) {
await queue.add('send-digest', {
userId: user.id,
variantKey,
});
}
}
}Cache invalidation patterns
Caching is only useful if you can invalidate stale data. Here are three safe patterns:
1) Version-based invalidation
// Increment version on deploy or template change
const TEMPLATE_VERSION = process.env.TEMPLATE_VERSION || '1';
export function getCacheKey(templateName: string, variant: string): string {
return `email:${templateName}:${variant}:v${TEMPLATE_VERSION}`;
}
// Old keys expire naturally via TTL
// New deploys use new keys automatically2) Short TTLs for dynamic data
For templates with semi-dynamic data (brand colors, feature flags), use short TTLs (5-15 minutes) instead of manual invalidation.
async function getCachedBrandTemplate(orgId: string, locale: string) {
const key = `brand:${orgId}:${locale}`;
const cached = await redis.get(key);
if (cached) return cached;
const brandColors = await db.organization.findUnique({
where: { id: orgId },
select: { primaryColor: true, logoUrl: true },
});
const html = render(<BrandedEmail {...brandColors} locale={locale} />);
// Expire after 10 minutes (balances freshness + cache hit rate)
await redis.setex(key, 600, html);
return html;
}3) Event-driven invalidation
For templates tied to mutable data (org settings, feature flags), invalidate on update webhooks.
export async function POST(req: Request) {
const { orgId, updatedFields } = await req.json();
// If brand settings changed, bust cache
if (updatedFields.includes('brandColors') || updatedFields.includes('logo')) {
const pattern = `brand:${orgId}:*`;
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
}
}
return new Response('OK');
}Monitoring and debugging
Caching can hide errors. Add instrumentation to catch issues early:
import { render } from '@react-email/render';
import { logger } from '@/lib/logger';
export async function renderWithCache<T>(
Component: React.ComponentType<T>,
props: T,
cacheKey: string
): Promise<string> {
const start = Date.now();
// Check cache
const cached = await redis.get(cacheKey);
if (cached) {
logger.info('Email cache hit', {
cacheKey,
duration: Date.now() - start,
});
return cached;
}
// Cache miss: render + store
logger.info('Email cache miss', { cacheKey });
try {
const html = render(<Component {...props} />);
await redis.setex(cacheKey, 3600, html);
logger.info('Email rendered and cached', {
cacheKey,
duration: Date.now() - start,
htmlSize: html.length,
});
return html;
} catch (error) {
logger.error('Email render failed', { cacheKey, error });
throw error;
}
}Track these metrics in your observability tool:
- Cache hit rate (target: 80%+)
- Average render time (cache miss)
- Cache storage size
- Template errors (caught during render)
Testing cached templates
Caching can mask bugs if not tested properly. Add these checks:
import { describe, test, expect, beforeEach } from 'vitest';
import { redis } from '@/lib/redis';
import { renderWithCache } from '@/lib/cached-render';
import { WelcomeEmail } from '@/emails/welcome';
describe('Email template caching', () => {
beforeEach(async () => {
// Clear cache before each test
await redis.flushdb();
});
test('cache miss renders template', async () => {
const html = await renderWithCache(
WelcomeEmail,
{ userName: 'Alice' },
'welcome:en'
);
expect(html).toContain('Welcome, Alice!');
});
test('cache hit returns same HTML', async () => {
const key = 'welcome:en';
const html1 = await renderWithCache(
WelcomeEmail,
{ userName: 'Alice' },
key
);
const html2 = await renderWithCache(
WelcomeEmail,
{ userName: 'Bob' }, // Different props
key // Same cache key
);
expect(html1).toBe(html2); // Cache ignores props
});
test('cache invalidation works', async () => {
const key = 'welcome:en';
await renderWithCache(WelcomeEmail, { userName: 'Alice' }, key);
await redis.del(key); // Invalidate
const html = await renderWithCache(
WelcomeEmail,
{ userName: 'Bob' },
key
);
expect(html).toContain('Welcome, Bob!'); // Fresh render
});
});When NOT to cache
Caching isn't always the answer. Skip it if:
- Every email is unique: User-specific data (names, balances, URLs) means zero cache hits.
- Render time is already fast: If templates render in <20ms, caching adds complexity with minimal gain.
- Low email volume: Sending 100 emails/day? The CPU savings don't justify the infrastructure.
- Templates change frequently: If you're A/B testing copy daily, cache invalidation becomes a bottleneck.
Production caching checklist
Before deploying cached email templates:
- ✅ Benchmark render times (baseline vs cached)
- ✅ Never cache user-specific data (only templates/layouts)
- ✅ Set appropriate TTLs (5-60 minutes)
- ✅ Version cache keys (for safe deploys)
- ✅ Add cache hit/miss logging
- ✅ Test cache invalidation
- ✅ Monitor cache storage size
- ✅ Have a fallback (direct render) if cache fails
Start simple (in-memory LRU), then graduate to Redis only if you need multi-instance caching or TTLs longer than 5 minutes.
Caching email templates is about leverage: render once, send thousands. Do it right and you'll cut CPU costs, speed up sends, and prevent timeout failures at scale.