I18next: Caching - How to configure an efficient caching strategies (browser and server) using Next.js framework and Locize

Created on 21 Dec 2019  路  4Comments  路  Source: i18next/i18next

I'm looking for guidelines regarding the caching strategies one should consider when configuring an universal application, such as when using the Next.js framework and an i18n API backend, such as Locize.

Existing documentation

I've read https://docs.locize.com/more/caching but I could make use of it, I don't understand what _(and also doesn't explain where to configure the cache-control, I spend a good 5mn looking around so I suggest the doc to be improved about that)_.

Concerns

Here are my concerns:

  • Bandwith
  • Cost
  • Data "freshness" (up-to-date translations)

Data "freshness"

The cache should always be updated when deploying a new version of the app, so that even if it was cached recently, it would invalidate the cache and fetch the most recent version. This ensures that a user navigating gets immediately a refreshed version of the locales, when a new version of the application is deployed in production.

Also, the cache should be temporary (1h seems alright), because translation corrections could be made at any time, and not be related to a new production deployment.

Current situation

I've noticed 22981 downloads in December, for my not-yet-in-production app. I'm basically the sole user and most of those downloads were made through my usage (development). It's huge, and clearly shows a rather bad caching configuration (and probably a multi-downloads issue per request).

Analysis

I use the Next.js framework (v9.x, powered by AWS Lambda), which is a universal framework that renders the app both from the server (SSR) and from the client (when performing client-side navigation)

I pre-fetch the translation files in the pages/_app:getInitialProps and initialize i18next with them as resources.

const defaultLocalesResponse = await fetch(locizeAPIEndpoint);
const defaultLocales = await defaultLocalesResponse.json();
// ...

i18nInstance.init({ // XXX See https://www.i18next.com/overview/configuration-options
    resources: defaultLocales,
    // ...
});

Therefore, this code is executed both by the server (running on AWS Lambda) and by the browser.

Currently, I believe the server does not cache anything.
And I'm not sure how the browser caching strategy works.

Still, 22k downloads is quite huge for a single developer. But, it can be explained because there is a lot of page refresh ((Next.js HMR in development), and no caching strategy on the latest Locize version, which forces real API request to be sent for every page reload (server), and every client side navigation.

Solutions implementation details

Server

I'm not quite sure how to perform caching from the server, it's complicated. AWS Lambda automatically caches things that are fetch outside of its handler function, but this ability is hidden when using Next since we don't manipulate AWS Lambda directly. I don't know if anyone is familiar with the framework itself, but due to its massive usage in the react/node community, I believe it'd be very useful to have some guidelines on this topic.

Browser

I'm not so familiar with caching strategies and I'd like some guidelines here as well, about what headers to send in a fetch request, for instance. The browser can cache its own version, and it should.

Navigating to another page (client side navigation) will perform another API request (with cache-control: public, must-revalidate, proxy-revalidate, max-age=0 in development), it's probably fine for development though, I'm not sure it can be improved because we do need the latest translation data when working on it. I haven't tested on production though, it may behave the same depending on what cache-controlheaders Locize API send by default, for versions that aren't latest.

Locize

The doc at https://docs.locize.com/more/caching could explain more in-depth how configuring cache-control max-age will impact the user experience. I'm not sure atm if it will affect the server, the browser, or both (I believe, both).

Also, I'm not sure how nicely it'll play with a new production deployment, I feel like it won't invalidate the cache in production when I deploy a new version, and I'd like to avoid that side-effect. (I could use a query parameter with the value of the deployment's ID for instance, but how can/should I implement it?)


It's not an easy topic, writing all this helped me understand the different pieces at hand. It's complex because the behaviour should/will change depending on multiple variables, such as:

  • Stage: We want to cache stuff on production, but not when working locally
  • Runtime engine: The cache implementation differs depending on the "runtime engine" (browser, server)
  • Cache invalidation: The cache invalidation depends itself on the stage and runtime engine, but can also be triggered by external factors, such as when deploying a new version of our app

N.B: My development and staging stages use Locize lastest version, while my production stage uses Locize production version.

Most helpful comment

Thank you for your in-depth answers, it's helped me a lot about what to do, in my particular use-case.

It was never my intention for Locize to "fix" this for us, as stated, it's a very case-by-case issue that cannot be solved globally. It's more about giving away the tools and knowledge for developers to make the right choices based on their own need.

I've implemented my own caching strategies, and documented it in my project. Here is the raw documentation, hope it helps people figuring out how to solve their own caching struggles. :)

/**
 * Explanation about our cache implementation:
 *  - Browser:
 *    * In-memory: Once the translations are fetched from Locize, they're memoized
 *      - Development/staging stages:
 *        - It's very useful in development/staging, where we don't have any browser caching enabled, because it'll reduce the API calls
 *          With this in-memory cache, the Locize API will be hit only once by the browser, and then the in-memory cache will take over when performing client-side navigation
 *        - The in-memory cache will be invalidated as soon as the page is refreshed (F5, cmd+r, etc.) and a new API call will be made
 *    * Browser's native cache:
 *      - Development/staging stages:
 *        - The browser's cache is completely disabled in development/staging, so that we may work/test with the latest translation
 *          See the official recommendations https://docs.locize.com/more/caching
 *      - Production stage:
 *        - When in production, the browser will also cache for 1h, because we configured a "Cache-control: Max-Age" at https://www.locize.io/p/uqjo3htd/settings
 *          We followed the official recommendations https://docs.locize.com/more/caching
 *          1h seems to be a good compromise between over-fetching (we don't expect our users to use the app for more than 1h straight)
 *          and applying translation "hot-fix" (worst case: "hot-fix"" be applied 1h later, which is acceptable)
 *        - The browser's native cache will be invalidated as soon as a new release of the app is deployed,
 *          because the value of "build" in the "loadPath" url will change and thus the browser will not use its cached version anymore
 *  - Server:
 *    * In-memory: Once the translations are fetched from Locize, they're memoized
 *      - Development:
 *        - Once the translations are memoized, they'll be kept in-memory until the server restarts
 *          Note that HMR will clear the cache, meaning any source code change that triggers a new build will thus invalidate the cache
 *      - Staging/production:
 *        - Once the translations are memoized, they'll be kept in-memory until... God knows what.
 *          - It's very hard to know how/when the memoized cache will be invalidated, as it's very much related to AWS Lambda lifecycle, which can be quite surprising
 *          - It will likely be invalidated within 5-15mn if there aren't any request performed against the Lambda (nobody using the app)
 *          - But if there is a steady usage of the app then it could be memoized for hours/days
 *          - If there is a burst usage, then new lambdas will be triggered, with their own version of the memoized cache
 *            (thus, depending on which Lambda serves the requests, the result could be different if they don't use the same memoized cache)
 *            This could be the most surprising, as you don't know which Lambda will serve you, so you may see a different content by refreshing the page
 *            (if browser cache is disabled, like when using the browser in Anonymous mode)
 *        - To sum up: The memoized cache will be invalidated when the server restarts
 *          - When a new release is deployed (this ensures that a new deployment invalidates the cache for all running Lambda instances at once)
 *          - When a Lambda that serves the app is started
 *        - The cache is automatically invalidated after 1h (see memoizedCacheMaxAge). Thus ensuring that all Lambdas will be in-sync every hour or so,
 *          even when hot-fixes are deployed on Locize
 *    * Server's native cache:
 *      - Development:
 *        - There is no known server-side caching abilities while in development, and we don't want to cache the content while in development
 *      - Staging/production:
 *        - The server doesn't have any native caching abilities. We're running under Next.js serverless mode (AWS Lambda), which has a /tmp directory where we could write
 *          (See https://stackoverflow.com/questions/48364250/write-to-tmp-directory-in-aws-lambda-with-python)
 *          But Next.js abstracts that away from us and we don't know how to write there. (would it be more efficient than the in-memory cache anyway?)
 *
*/

Memoization was simple enough using a variable that stores the memoized value once fetched, here are the TS types:

/**
 * A resource locale can be either using a "flat" format or a "nested" format
 *
 * @example
 * {
 *    "login": {
 *        "label": "Log in",
 *        "user": "User Name"
 *    }
 * }
 *
 * @example
 * {
 *    "login.label": "Log in",
 *    "login.user": "User Name",
 *    "User not found!": "User not found!"
 * }
 */
export declare type I18nextResourceLocale = {
  [i18nKey: string]: string | I18nextResourceLocale; // The value can either be a string, or a nested object, itself containing either a string, or a nest object, etc.
}

/**
 * One or more i18next resources, indexed by lang
 *
 * @example
 * {
 *   fr: {
 *     "login": {
 *       "label": "Log in",
 *       "user": "User Name"
 *     }
 *   }
 * }
 */
export declare type I18nextResources = {
  [lang: string]: I18nextResourceLocale;
}

/**
 * Memoized i18next resources are timestamped, to allow for cache invalidation strategies
 * The timestamp's value is the time when the memoized cache was created
 */
export declare type MemoizedI18nextResources = {
  resources: I18nextResources;
  ts: number; // Timestamp in milliseconds
}

/**
 * In-memory cache of the i18next resources
 *
 * Useful to avoid over-fetching the Locize API, but rather rely on the memoized cache when available
 * Each locizeAPIEndpoint contains its own cache, thus handling different projects, versions, languages and namespaces
 *
 * @type {I18nextResources}
 * @private
 */
const _memoizedI18nextResources: {
  [locizeAPIEndpoint: string]: MemoizedI18nextResources;
} = {};

/**
 * Max age of the memoized cache (value in seconds)
 *
 * Once the cache is older than max age, it gets invalidated
 * This is used in both the browser and the server, but it's meant to be the most useful on the server
 * - The browser will reset its own memoized cache as soon as the user refreshes the page (f5, etc.), so it's very not likely to ever reach max age
 * - The server, on the other hand, may very much reach max age and needs to perform some cache invalidation from time to time,
 *    to make sure we use the latest translations
 *
 * @type {number}
 */
const memoizedCacheMaxAge = 3600;

Don't hesitate to correct me if I misunderstood/misexplained things in the documentation (the bits about AWS Lambda internals is complex), as I mean to release this under an OSS boilerplate project in the upcoming weeks, it'll eventually be useful to other people!

All 4 comments

Regarding Cache-Control in locize:
Navigate to your project settings and click on the more menu in the versions section:
image
This is "normal" Cache-Control (nothing custom to locize) It's a HTTP feature: https://www.imperva.com/learn/performance/cache-control/ and normally evaluated only in browser.

It's also used to optimize latency: https://faq.locize.com/#/misc/latency-optimization-tips-for-optimal-performance

Regarding server side caching, for normal serverside usage i18next-node-locize-backend will automatically refresh the translations: https://github.com/locize/i18next-node-locize-backend/blob/master/src/index.js#L31 each hour (default)

By directly fetching these translations on serverless side you are doing more or less the same.

For serverless usage we really recommend not to fetch the translations at runtime, but bundle the translations in your serverless function: https://github.com/locize/i18next-node-locize-backend#important-advice-for-serverless-environments---aws-lambda-google-cloud-functions-azure-functions-etc

For example you can register a webhook which listens to versionPublished: https://docs.locize.com/integration/webhook#versionpublished and then download the translations and deploy the serverless function.... or automatically download the latest translations on each serverless deployment.

Regarding next.js perhaps @isaachinman has some ideas?

While this is loosely related to serverless support, it's not something that next-i18next is ever going to solve for users. Caching is, and will always be, a hard problem to solve. Moreover, it tends to be unique for each individual case. Here's my general advice: treat translations as a deployment. If you can make that assumption, things become a lot easier.

Server side:
No matter where your data is stored, you will always want to hold it in memory after reading whenever possible.

Per deployment, write translation files to the filesystem. Reading from the filesystem will always be faster than network requests.

If you cannot use a filesystem, you should cache/memoise your network request. If we are assuming that our translations are per-deployment, pinging for a 304 is a waste of valuable SSR time.

Side note: I haven't seen it in the pure-JS world, but in a recent React/Rails project, we had great success simply appending an md5 of the translation content to the asset, and storing the filenames in a manifest. This solves all caching problems immediately.

Browser:
The translation data needed to render the initially-requested view should be serialised to the HTML doc and sent down. All dynamically-needed data should be requested over network lazily as needed.

Whatever service serves your translation data (express API, CDN, whatever) will need to set appropriate headers for caching. Again, setting an md5 in the path and simply caching everything forever is the simplest option.

Thank you for your in-depth answers, it's helped me a lot about what to do, in my particular use-case.

It was never my intention for Locize to "fix" this for us, as stated, it's a very case-by-case issue that cannot be solved globally. It's more about giving away the tools and knowledge for developers to make the right choices based on their own need.

I've implemented my own caching strategies, and documented it in my project. Here is the raw documentation, hope it helps people figuring out how to solve their own caching struggles. :)

/**
 * Explanation about our cache implementation:
 *  - Browser:
 *    * In-memory: Once the translations are fetched from Locize, they're memoized
 *      - Development/staging stages:
 *        - It's very useful in development/staging, where we don't have any browser caching enabled, because it'll reduce the API calls
 *          With this in-memory cache, the Locize API will be hit only once by the browser, and then the in-memory cache will take over when performing client-side navigation
 *        - The in-memory cache will be invalidated as soon as the page is refreshed (F5, cmd+r, etc.) and a new API call will be made
 *    * Browser's native cache:
 *      - Development/staging stages:
 *        - The browser's cache is completely disabled in development/staging, so that we may work/test with the latest translation
 *          See the official recommendations https://docs.locize.com/more/caching
 *      - Production stage:
 *        - When in production, the browser will also cache for 1h, because we configured a "Cache-control: Max-Age" at https://www.locize.io/p/uqjo3htd/settings
 *          We followed the official recommendations https://docs.locize.com/more/caching
 *          1h seems to be a good compromise between over-fetching (we don't expect our users to use the app for more than 1h straight)
 *          and applying translation "hot-fix" (worst case: "hot-fix"" be applied 1h later, which is acceptable)
 *        - The browser's native cache will be invalidated as soon as a new release of the app is deployed,
 *          because the value of "build" in the "loadPath" url will change and thus the browser will not use its cached version anymore
 *  - Server:
 *    * In-memory: Once the translations are fetched from Locize, they're memoized
 *      - Development:
 *        - Once the translations are memoized, they'll be kept in-memory until the server restarts
 *          Note that HMR will clear the cache, meaning any source code change that triggers a new build will thus invalidate the cache
 *      - Staging/production:
 *        - Once the translations are memoized, they'll be kept in-memory until... God knows what.
 *          - It's very hard to know how/when the memoized cache will be invalidated, as it's very much related to AWS Lambda lifecycle, which can be quite surprising
 *          - It will likely be invalidated within 5-15mn if there aren't any request performed against the Lambda (nobody using the app)
 *          - But if there is a steady usage of the app then it could be memoized for hours/days
 *          - If there is a burst usage, then new lambdas will be triggered, with their own version of the memoized cache
 *            (thus, depending on which Lambda serves the requests, the result could be different if they don't use the same memoized cache)
 *            This could be the most surprising, as you don't know which Lambda will serve you, so you may see a different content by refreshing the page
 *            (if browser cache is disabled, like when using the browser in Anonymous mode)
 *        - To sum up: The memoized cache will be invalidated when the server restarts
 *          - When a new release is deployed (this ensures that a new deployment invalidates the cache for all running Lambda instances at once)
 *          - When a Lambda that serves the app is started
 *        - The cache is automatically invalidated after 1h (see memoizedCacheMaxAge). Thus ensuring that all Lambdas will be in-sync every hour or so,
 *          even when hot-fixes are deployed on Locize
 *    * Server's native cache:
 *      - Development:
 *        - There is no known server-side caching abilities while in development, and we don't want to cache the content while in development
 *      - Staging/production:
 *        - The server doesn't have any native caching abilities. We're running under Next.js serverless mode (AWS Lambda), which has a /tmp directory where we could write
 *          (See https://stackoverflow.com/questions/48364250/write-to-tmp-directory-in-aws-lambda-with-python)
 *          But Next.js abstracts that away from us and we don't know how to write there. (would it be more efficient than the in-memory cache anyway?)
 *
*/

Memoization was simple enough using a variable that stores the memoized value once fetched, here are the TS types:

/**
 * A resource locale can be either using a "flat" format or a "nested" format
 *
 * @example
 * {
 *    "login": {
 *        "label": "Log in",
 *        "user": "User Name"
 *    }
 * }
 *
 * @example
 * {
 *    "login.label": "Log in",
 *    "login.user": "User Name",
 *    "User not found!": "User not found!"
 * }
 */
export declare type I18nextResourceLocale = {
  [i18nKey: string]: string | I18nextResourceLocale; // The value can either be a string, or a nested object, itself containing either a string, or a nest object, etc.
}

/**
 * One or more i18next resources, indexed by lang
 *
 * @example
 * {
 *   fr: {
 *     "login": {
 *       "label": "Log in",
 *       "user": "User Name"
 *     }
 *   }
 * }
 */
export declare type I18nextResources = {
  [lang: string]: I18nextResourceLocale;
}

/**
 * Memoized i18next resources are timestamped, to allow for cache invalidation strategies
 * The timestamp's value is the time when the memoized cache was created
 */
export declare type MemoizedI18nextResources = {
  resources: I18nextResources;
  ts: number; // Timestamp in milliseconds
}

/**
 * In-memory cache of the i18next resources
 *
 * Useful to avoid over-fetching the Locize API, but rather rely on the memoized cache when available
 * Each locizeAPIEndpoint contains its own cache, thus handling different projects, versions, languages and namespaces
 *
 * @type {I18nextResources}
 * @private
 */
const _memoizedI18nextResources: {
  [locizeAPIEndpoint: string]: MemoizedI18nextResources;
} = {};

/**
 * Max age of the memoized cache (value in seconds)
 *
 * Once the cache is older than max age, it gets invalidated
 * This is used in both the browser and the server, but it's meant to be the most useful on the server
 * - The browser will reset its own memoized cache as soon as the user refreshes the page (f5, etc.), so it's very not likely to ever reach max age
 * - The server, on the other hand, may very much reach max age and needs to perform some cache invalidation from time to time,
 *    to make sure we use the latest translations
 *
 * @type {number}
 */
const memoizedCacheMaxAge = 3600;

Don't hesitate to correct me if I misunderstood/misexplained things in the documentation (the bits about AWS Lambda internals is complex), as I mean to release this under an OSS boilerplate project in the upcoming weeks, it'll eventually be useful to other people!

Was this page helpful?
0 / 5 - 0 ratings

Related issues

dmorrison42 picture dmorrison42  路  7Comments

rahulthewall picture rahulthewall  路  4Comments

chiouchu picture chiouchu  路  4Comments

karol-majewski picture karol-majewski  路  6Comments

yann-combarnous picture yann-combarnous  路  4Comments