Code Tips13 min read

Email Template Caching Strategies: Cut Render Times from 500ms to 50ms at Scale

Production-ready caching strategies for React Email templates: static pre-rendering, Redis caching, LRU caches, background pre-generation, and cache invalidation patterns that prevent stale sends.

R

React Emails Pro

March 4, 2026

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.

Template caching isn't premature optimization—it's the difference between 50ms and 500ms render times at scale, and the line between smooth sends and rate limit exhaustion.

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.

Benchmark first: Measure your actual render times with 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.

lib/email-cache.ts
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
  });
}
When to use: Announcement emails, marketing blasts, newsletters. Any template where the content is identical for all recipients.

2) Memoized partial rendering for shared layouts

Most emails share a layout (header, footer, brand colors). Cache the layout separately from the dynamic content.

components/email-layout.tsx
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>
  );
}
When to use: Multi-tenant SaaS where brand colors/logos change per organization, but layout structure is shared.

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.

lib/send-verification.ts
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,
  });
}
Security note: Only cache templates with placeholders, never with real user data. Replace dynamic values after retrieving from cache.

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.

lib/template-cache.ts
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)
);
When to use: Apps with predictable template usage patterns (e.g., most users trigger the same 5-10 template variations).

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.

jobs/prerender-digest.ts
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,
      });
    }
  }
}
ROI calculation: If you're sending 10K digests and each render takes 100ms, pre-rendering saves ~16 minutes of compute time per send.

Cache invalidation patterns

Caching is only useful if you can invalidate stale data. Here are three safe patterns:

1) Version-based invalidation

lib/cache-keys.ts
// 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 automatically

2) 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.

lib/brand-cache.ts
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.

api/webhooks/org-updated.ts
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:

lib/cached-render.ts
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:

__tests__/email-cache.test.ts
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.
Don't cache to fix slow templates—optimize the template first. Caching is for scaling fast templates, not hiding slow ones.

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.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates