Skip to content

SSR Caching — Caching Server-Rendered Pages for Performance

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about SSR Caching. We cover key concepts, practical examples, and best practices to help you master this topic.

SSR caching stores rendered HTML to serve subsequent requests without re-rendering, using in-memory caches, Redis, CDN edge caching, and fragment caching strategies for optimal performance.

What You'll Learn

By the end of this tutorial, you will understand why SSR caching is essential, how to implement in-memory and Redis caching for full pages, fragment caching for reusable components, CDN edge caching with cache-control headers, and cache invalidation strategies.

Why It Matters

SSR renders every page on every request. For high-traffic pages, this means the server does the same work thousands of times per second. Without caching, a single popular page can overwhelm the server. Caching reduces server load by 90-99 percent and improves response times from hundreds of milliseconds to single milliseconds.

Real-World Use

An e-commerce site reduced server load from 10,000 requests per second to 200 by implementing Redis caching for popular product pages. Response times dropped from 400ms to 8ms. They saved 80 percent on server costs while improving user experience. The cache served 98 percent of product page requests.

SSR Caching Layers
    ┌──────────────────────────────────────────────────────────┐
    │              SSR Caching Layers                          │
    ├──────────────────────────────────────────────────────────┤
    │                                                          │
    │  1. CDN Edge Cache (Fastly, CloudFront, Cloudflare)     │
    │     • Closest to user, fastest response                  │
    │     • Caches full HTML pages                             │
    │     • TTL: 5-60 minutes                                  │
    │     • Invalidate via purge API                          │
    │                                                          │
    │  2. Reverse Proxy (Nginx, Varnish)                      │
    │     • Caches behind the server                           │
    │     • Configurable rules per URL pattern                 │
    │     • TTL: 1-5 minutes                                   │
    │                                                          │
    │  3. Application Cache (Redis, Memcached)                │
    │     • Caches rendered HTML in memory                    │
    │     • Fast reads (1-5ms)                                 │
    │     • Supports fragment caching                         │
    │                                                          │
    │  4. In-Memory Cache (Node.js heap)                      │
    │     • Fastest (no network call)                          │
    │     • Limited by server memory                          │
    │     • Lost on server restart                            │
    │                                                          │
    └──────────────────────────────────────────────────────────┘

Think of SSR caching like a bakery with multiple display cases. The CDN is the front counter — customers grab pre-packaged items immediately. Redis is the back counter with frequently-ordered cakes ready to serve. In-memory cache is the baker's bench with the next batch cooling. Each layer reduces how often the baker must start from scratch.

Redis Page Caching

const express = require('express');
const { createClient } = require('redis');
const { renderToPipeableStream } = require('react-dom/server');

const redis = createClient();
redis.connect();

// SSR with Redis caching
async function cachedSSR(req, res, component, cacheTTL = 60) {
    const cacheKey = `ssr:${req.originalUrl}`;

    // 1. Check cache
    try {
        const cached = await redis.get(cacheKey);
        if (cached) {
            console.log('Cache HIT:', cacheKey);
            res.setHeader('X-Cache', 'HIT');
            return res.send(cached);
        }
    } catch (err) {
        console.error('Cache read error:', err);
    }

    // 2. Not cached — render and cache
    console.log('Cache MISS:', cacheKey);
    let html = '';

    const { pipe } = renderToPipeableStream(component, {
        onShellReady() {
            res.setHeader('Content-Type', 'text/html');
            res.write('<div id="root">');
            pipe(res);
        },
        onAllReady() {
            res.end('</div>');
        }
    });

    // Capture the HTML for caching
    // In practice, use a writable stream to capture output
    const chunks = [];
    const originalWrite = res.write.bind(res);
    res.write = (chunk) => {
        chunks.push(chunk);
        return originalWrite(chunk);
    };

    res.on('finish', async () => {
        const fullHtml = chunks.join('');
        await redis.setEx(cacheKey, cacheTTL, fullHtml);
    });
}

// Usage
app.get('/products/:id', async (req, res) => {
    const ProductPage = await import('./pages/ProductPage');
    cachedSSR(req, res,
        React.createElement(ProductPage.default, { id: req.params.id }),
        300 // Cache for 5 minutes
    );
});

Fragment Caching

// Cache reusable component fragments
class FragmentCache {
    constructor(redisClient) {
        this.redis = redisClient;
    }

    async getOrRender(key, renderFn, ttl = 300) {
        // Try cache
        const cached = await this.redis.get(`fragment:${key}`);
        if (cached) {
            return cached;
        }

        // Render fragment
        const html = await renderFn();

        // Store in cache
        await this.redis.setEx(`fragment:${key}`, ttl, html);

        return html;
    }

    async invalidate(pattern) {
        const keys = await this.redis.keys(`fragment:${pattern}`);
        if (keys.length > 0) {
            await this.redis.del(keys);
        }
    }
}

const fragmentCache = new FragmentCache(redis);

// Usage in SSR
app.get('/products/:id', async (req, res) => {
    const product = await db.products.findById(req.params.id);

    // Cache the product details fragment (same for all users)
    const productHtml = await fragmentCache.getOrRender(
        `product:${product.id}`,
        () => renderToString(
            React.createElement(ProductDetails, { product })
        ),
        600 // 10 minutes
    );

    // Cache the sidebar (same for all users)
    const sidebarHtml = await fragmentCache.getOrRender(
        'sidebar:categories',
        () => renderToString(React.createElement(CategorySidebar)),
        300 // 5 minutes
    );

    // Render personalized content (not cached)
    const userHeader = await renderToString(
        React.createElement(UserHeader, { user: req.user })
    );

    // Assemble the page
    res.send(assemblePage({ productHtml, sidebarHtml, userHeader }));
});

Common Mistakes

  1. Caching personalized content. Never cache pages with user-specific data (username, cart items, recommendations). These should bypass the cache or use ESI (Edge Side Includes).
  2. Not invalidating cache on content update. When content changes, the cache must be invalidated. Use Webhooks, database triggers, or TTL-based expiration.
  3. Caching too long. Long cache TTLs serve stale content. Balance freshness with performance. Use stale-while-revalidate for serving stale content while fetching fresh data.
  4. No cache key for query parameters. URLs with different query parameters should have different cache keys. Otherwise, /search?q=react and /search?q=vue serve the same cached page.
  5. Cache stampede. When a popular cache key expires, multiple requests may all try to re-render simultaneously. Use mutex locks or stale-while-revalidate to prevent this.

Practice Questions

  1. What are the four layers of SSR caching?
  2. How does Redis page caching work for SSR?
  3. What is fragment caching and when should you use it?
  4. How do you invalidate cache when content changes?
  5. What is cache stampede and how do you prevent it?

Challenge: Implement a multi-layer caching system for an SSR application: Redis page caching for public product pages with 5-minute TTL, fragment caching for navigation and sidebar, CDN cache headers set via response headers, cache invalidation via Webhook when content is updated, and cache stampede protection with mutex locks.

FAQ

What is the best cache TTL for SSR pages?

Depends on how often content changes. Public blog posts: 5-60 minutes. Product pages: 1-5 minutes. News articles: 1-10 minutes. User-specific pages: no cache (or short TTL with CDN.

How do I handle cache for authenticated users?

Use Vary: Cookie header to cache different versions for different users. Or bypass the cache for authenticated users and only cache public pages.

Does caching work with streaming SSR?

Yes, but it is more complex. Cache the final assembled HTML after streaming completes. Or use fragment caching for individual components.

How do I measure cache hit ratio?

Track cache hits vs misses in your logging. A good cache hit ratio is 90%+. Monitor and optimize low-hit-rate cache keys.

Can I use serverless functions with SSR caching?

Serverless functions can use CDN caching and external Redis. However, cold starts may increase latency for cache misses.

Mini Project

Implement SSR caching for a blog application: Redis full-page caching for the home page and blog posts with 5-minute TTL, fragment caching for the author bio and related posts sections, cache invalidation triggered by webhook when a new post is published, Vary: Cookie header for admin preview, and cache hit ratio monitoring.

What's Next

You understand SSR caching. Now explore Redis Caching for advanced caching with Redis.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro