SSR Request Cache for React / NEXT.JS with Cacheable-Response (English Version)
27. August 2019

SSR Request Cache für React / NEXT.JS mit Cacheable-Response

Code excerpt

Auf dieser Seite wird beschrieben, wie man mittels Cacheable-Response einen In-Memory Request Cache in eine React bzw. Next.JS Applikation einbaut. In diesem Beispiel wird Express Server verwendet, es kann aber ohne weiteres auf React und NextJS angewandt werden.

Das Problem

Setzt man React ein, so denken viele im ersten Moment eher an das Entwickeln eine modernen, „geilen“ Applikation in einem Tech-Stack, der Spaß macht. Wenige denken bereits so früh an den Live-Betrieb der Applikation. Spätestens aber, wenn das Thema SEO und Indizierbarkeit angegangen wird, kommt Next.JS ins Spiel.

Mit Next.JS lassen sich React Applikation isomorph serverseitig rendern und ausspielen (SSR). Der Benutzer bekommt also eine fertig gerenderte Seite bevor es dann weitergeht mit dem gewohnten API-basierten Browsen.

Wirft man dann jedoch einen Blick auf die Performance von Next.JS sieht man:

Next.JS ist langsam! (Insbesondere bei komplexen Seiten)

Dies hat zur Folge, dass die SEO-relevante Time-To-First-Byte (TTFB), also die Zeit, die verstreicht, bis der Webserver das erste Byte der Seite überträgt stark ansteigt (TTFB). In meinen Projekten lag diese (trotz starker Server im Produktionsbetrieb) bei teilweise > 1.7 Sekunden! Viel zu lang.

Wie üblich in einem solchen Fall würde man das Problem auf der Infrastruktur-Ebene Lösen und einen Proxy Cache vorschalten (z.B. nginx). Im Folgenden möchte ich aber den „React“-Weg aufzeigen. Dieser hat folgende Vorteile:

  • Leichtes Testen des Cachings während dem Entwickeln
  • Individuelle Cache-Konfiguration auf Basis von Express- bzw. NextJS Routen möglich
  • Keine zusätzliche Komplexität – Man kann wie gewohnt Express-Server nutzen, ohne sich mit Betriebsthematiken näher auseinanderzusetzen
  • Bei Bedarf ist die Cache-Configuration leicht über Umgebungs-Variablen zu managen
  • Deployment in Container bleibt weiterhin einfach und Skalierbar
  • Weniger Hops eingehender Requests durch zusätzlichen Reverse-Proxy
  • Blaupause für weitere Caches (z.B. API Request Caches)

Lösung

Um einen Request Cache in React einzubauen geht man also wie folgt vor:

  1. Cache-Store / Cache-Manager einrichten
  2. Nicht-Zu-Cachende Routen ausschließen (z.B. alles unter /_next/*)
  3. Zu-Cachende Routen über den Cache-Manager laufen lassen.
  4. URL-Basiertes Cache-Purging einrichten
  5. Gesamten-Cache-Leeren Funktion einrichten

Simple Lösung

Zunächst muss das Paket installiert werden:

npm install --save cacheable-response

Mit Cacheable-Response einen einfachen Cache-Manager anlegen. Die Funktion im get-Block beschreibt die Funktion, die genau dann ausgeführt wird, wenn Daten erstmalig oder erneut gecached werden sollen.

Der send Block beschreibt die Funktion bei Cache-Zugriff. Weitere Informationen zu Cacheable-Response findet man hier: https://www.npmjs.com/package/cacheable-response


const express = require('express');
const next = require('next');
const cacheableResponse = require('cacheable-response')

const isDevEnvironment = process.env.NODE_ENV !== 'production'
const nextApp = next({dev: isDevEnvironment, dir: './src'});

const defaultRequestHandler = nextApp.getRequestHandler();

const cacheManager = cacheableResponse({
    ttl: 1000 * 60 * 60, // 1hour
    get: async ({req, res, pagePath, queryParams}) => {
        try {
            return {data: await nextApp.renderToHTML(req, res, pagePath, queryParams)}
        } catch (e) {
            return {data: "error: " + e}
        }
    },
    send: ({data, res}) => {
        res.send(data);
    }
});

nextApp.prepare()
[...]

Anschließend muss man den Render- bzw. Handler-Befehl durch den Cache Manager ersetzen:


//server.get('*', (req, res) => app.render(req, res, '/index'));
//server.get('*', (req, res) => app.render(req, res, req.url, req.query));
//server.get('*', (req, res) => handle(req, res);

// Serving next data directly without the cache
server.get('/_next/*', (req, res) => {
    defaultRequestHandler(req, res);
});

server.get('*', (req, res) => {
    if (isDevEnvironment || req.query.noCache)
        res.setHeader('X-Cache-Status', 'DISABLED');
        defaultRequestHandler(req, res);
    } else {
        cacheManager({req, res, pagePath: req.path});
    }
});

Wichtig! pagePath beschreibt hier die jeweilige Next.JS Page (in diesem Fall ‚pages/index.js)

Die komplette server.js sieht dann wie folgt aus:


const express = require('express');
const next = require('next');
const cacheableResponse = require('cacheable-response')

const isDevEnvironment = process.env.NODE_ENV !== 'production'
const nextApp = next({dev: isDevEnvironment, dir: './src'});

const defaultRequestHandler = nextApp.getRequestHandler();

const cacheManager = cacheableResponse({
    ttl: 1000 * 60 * 60, // 1hour
    get: async ({req, res, pagePath, queryParams}) => {
        try {
            return {data: await nextApp.renderToHTML(req, res, pagePath, queryParams)}
        } catch (e) {
            return {data: "error: " + e}
        }
    },
    send: ({data, res}) => {
        res.send(data);
    }
});

nextApp.prepare()
    .then(() => {
        const server = express();

        // Serving next data directly without the cache
        server.get('/_next/*', (req, res) => {
            defaultRequestHandler(req, res);
        });

        server.get('*', (req, res) => {
            if (isDevEnvironment || req.query.noCache) {
                res.setHeader('X-Cache-Status', 'DISABLED');
                defaultRequestHandler(req, res);
            } else {
                cacheManager({req, res, pagePath: req.path, queryParams: req.query});
            }
        });

        server.listen(3000, (err) => {
            if (err) throw err
            console.log('> Ready on http://localhost:3000')
        })
    })
    .catch((ex) => {
        console.error(ex.stack)
        process.exit(1)
    });

Komplex (mit Clear Cache / Cache Purging)

Jetzt wird das obige Beispiel um ein paar nützliche Funktionen erweitert:

  • Eigener Key-Generator (der default Algorithmus von ‚Cacheable-Response‘ wird für das Purging verwendet) – Damit kann man dedizierte URLs aus dem Cache entfernen
  • CacheStore manuell verwalten – So kann man überhaupt den Cache verwalten
  • Eigenes Purging von einzelnen URLs
  • Löschen des gesamten Caches
  • Kompression des Caches um Speicher zu sparen

Zunächst wird eine weitere Abhängigkeit installiert:

npm install --save iltorb cacheable-response

Im Folgenden wird der Cache mit dem HTTP Kommando PURGE gelöscht. Eine Alternative / Ergänzung wäre ein Löschen mittels Request Parameter. Die PURGE-Variante ist allerdings der sauberere Weg.


const express = require('express');
const next = require('next');
const Keyv = require('keyv');
const {resolve: urlResolve} = require('url');
const normalizeUrl = require('normalize-url');
const cacheableResponse = require('cacheable-response');


const isDevEnvironment = process.env.NODE_ENV !== 'production';

const nextApp = next({dev: isDevEnvironment, dir: './src'});

const defaultRequestHandler = nextApp.getRequestHandler();

const cacheStore = new Keyv({namespace: 'ssr-cache'});

const _getSSRCacheKey = req => {
    const url = urlResolve('http://localhost', req.url);
    const {origin} = new URL(url);
    const baseKey = normalizeUrl(url, {
        removeQueryParameters: [
            'embed',
            'filter',
            'force',
            'proxy',
            'ref',
            /^utm_\w+/i
        ]
    });
    return baseKey.replace(origin, '').replace('/?', '')
};

const cacheManager = cacheableResponse({
    ttl: 1000 * 60 * 60, // 1hour
    get: async ({req, res, pagePath, queryParams}) => {
        try {
            return {data: await nextApp.renderToHTML(req, res, pagePath, queryParams)}
        } catch (e) {
            return {data: "error: " + e}
        }
    },
    send: ({data, res}) => {
        res.send(data);
    },
    cache: cacheStore,
    getKey: _getSSRCacheKey,
    compress: true
});

function clearCompleteCache(res, req) {
    cacheStore.clear();
    res.status(200);
    res.send({
        path: req.hostname + req.baseUrl,
        purged: true,
        clearedCompleteCache: true
    });
    res.end();
}

function clearCacheForRequestUrl(req, res) {
    let key = _getSSRCacheKey(req);
    console.log(key);
    cacheStore.delete(key);
    res.status(200);
    res.send({
        path: req.hostname + req.baseUrl + req.path,
        key: key,
        purged: true,
        clearedCompleteCache: false
    });
    res.end();
}

nextApp.prepare()
    .then(() => {
        const server = express();

        // Do not use caching for _next files
        server.get('/_next/*', (req, res) => {
            defaultRequestHandler(req, res);
        });

        server.get('*', (req, res) => {
            if (isDevEnvironment || req.query.noCache) {
                res.setHeader('X-Cache-Status', 'DISABLED');
                defaultRequestHandler(req, res);
            } else {
                cacheManager({req, res, pagePath: req.path});
            }
        });

        server.purge('*', (req, res) => {
            if (req.query.clearCache) {
                clearCompleteCache(res, req);
            } else {
                clearCacheForRequestUrl(req, res);
            }
        });

        server.listen(3000, (err) => {
            if (err) throw err;
            console.log('> Ready on http://localhost:3000')
        })
    })
    .catch((ex) => {
        console.error(ex.stack);
        process.exit(1)
    });

Um zu testen, ob der Cache funktioniert, kann man sich den Response Header anschauen. Dort sollte beim ersten Aufrauf der Eintrag „X-Cache-Status: MISS“, im zweiten der Eintrag „X-Cache-Status: HIT“ und in der DEV-Umgebung bzw. wenn der Parameter „?noCache=true“ mitgegeben wurde ein „X-Cache-Status: DISABLED“ stehen:

Vor- und Nachteile

Mit Cacheable-Response hat man den Vorteil, dass einem viel Boylerplate Code abgenommen wird.

Der Nachteil ist aber ganz klar, dass man keine Kontrolle darüber hat, wie groß der Cache wird. Insbesondere bei großen Seiten mit einer komplexen Sitemap kann unter Umständen der Speicherverbrauch stark ansteigen.

In einem weiteren Artikel werde ich beschreiben, wie man dieses Problem mit einem etwas komplexeren Ansatz mittels LRU-Cache lösen kann. Eine einfach Variante findet man aber auch hier: Speeding up next.js application (server side in-memory caching via LRUcache).

Fazit

Auch wenn ich normalerweise immer gerne mit der einfacheren Lösung gehe, finde ich hier eine Implementation mit einem LRU Cache sinnvoller. Insbesondere die Möglichkeit, die Größe des Caches zu konfigurieren ist in meinen Augen eine Stärke, um das Risiko von Memoryleaks durch Caching zu reduzieren.

Spielt Speicher jedoch keine Rolle (weil z.B. genügend vorhanden ist oder die Applikation nicht so groß ist, dass man hier in Bedrängnis geraten könnte) ist ein Request Cache mit „Cacheable-Response“ alleine schon wegen der einfachen und kompfortablen Benutzbarkeit meine erste Wahl.

Ich hoffe, ich konnte jemandem weiterhelfen 😁

Referenzen

Es können keine Kommentare abgegeben werden.