React Admin 3.3: Faster Navigation And Reduced Footprint Thanks To Application Cache

François Zaninotto
François ZaninottoMarch 06, 2020
#react#react-admin#tech4good

Application Cache, an innovation we've just released in react-admin 3.3, allows to save up to 50% scripting time and a lot of bandwidth in your react-admin applications.

Rationale

We're focused on reducing the environmental footprint of react-admin. Since react-admin is widely used, any optimization we do in CPU, memory, or network consumption multiplies by a gazillion:

  • number of react-admin apps
  • x number of users of these apps
  • x number of times they use the app

...and we progress in slowing down global warming.

According to a March 2019 report by The Shift Project, a French nonprofit that aims to limit climate change, datacenter and network usage account for 35% of the energy consumption of the IT industry globally. That means that if a web app doesn't hit the server, it can save a lot of energy.

That decided us to build a brand new caching system inside react-admin. We call it the Application Cache, it's opt-in, and it's spectacular. Our benchmarks show a decrease in scripting time up to 50%, a huge reduction in network traffic, and a much smoother user experience. It's now live in version 3.3.

This post explains how to configure application cache for your react-admin application. But before that, it's necessary to clarify the caching optimizations already at work in react-admin v3 (namely optimistic renreding and HTTP cache)

Tip: The Shift Project "Lean ICT" report is full of crucial facts about the influence of IT on climate change, so I warmly encourage you to read it - it's only the 2 pages.

Cables by Thomas Jansen

Optimistic Rendering (Not New)

By default, react-admin stores all the responses from the dataProvider in the Redux store. This allows displaying the cached result first while fetching for the fresh data. This behavior is automatic and requires no configuration.

The Redux store is like a local replica of the API, organized by resource, and shared between all the data provider methods of a given resource. That means that if the getList('posts') response contains a record of id 123, a call to getOne('posts', { id: 123 }) will use that record immediately.

For instance, if the end-user displays a list of posts, then clicks on a post in the list to display the list details, here is what react-admin does:

  1. Display the empty List
  2. Call dataProvider.getList('posts'), and store the result in the Redux store
  3. Re-render the List with the data from the Redux store
  4. When the user clicks on a post, display immediately the post from the Redux store
  5. Call dataProvider.getOne('posts', { id: 123 }), and store the result in the Redux store
  6. Re-render the detail with the data from the Redux store

In step 4, react-admin displays the post before fetching it, because it's already in the Redux store from the previous getList() call. In most cases, the post from the getOne() response is the same as the one from the getList() response, so the re-render of step 6 is invisible to the end-user. If the post was modified on the server side between the getList() and the getOne calls, the end-user will briefly see the outdated version (at step 4), then the up to date version (at step 6).

Optimistic rendering improves user experience by displaying stale data while getting fresh data from the API, but it does not reduce the ecological footprint of an app, as the web app still makes API requests on every page.

Tip: This design choice explains why react-admin requires that all data provider methods return records of the same shape for a given resource. Otherwise, if the posts returned by getList() contain fewer fields than the posts returned by getOne(), in the previous scenario, the user will see an incomplete post at step 4.

HTTP Cache (Not New)

React-admin supports HTTP cache headers by default, provided your API sends them.

Data providers almost always rely on window.fetch() to call the HTTP API. React-admin's fetchJSON() and third-party libraries like axios use window.fetch(), too. Fortunately, the window.fetch() HTTP client behaves just like your browser and follows the RFC 7234 about HTTP cache headers. So if your API includes one of the following cache headers, all data providers support them:

  • Cache-Control
  • Expires
  • ETag
  • Last-Modified

In other words, enabling the HTTP cache is entirely a server-side action - nothing is necessary on the react-admin side.

For instance, let's imagine that your data provider translates a getOne('posts', { id: 123 }) call into a GET https://api.acme.com/posts/123, and that the server returns the following response:

HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Cache-Control: max-age=120
Age: 0
{
    "id": 123,
    "title": "Hello, world"
}

The important part is the Cache-Control header. It tells the browser that the response is valid for the next 2 minutes. If a component makes a new call to getOne('posts', { id: 123 }) within 2 minutes, window.fetch() will return the response from the first call without even calling the API.

Refer to your backend framework or CDN documentation to enable cache headers - and don't forget to whitelist these headers in the Access-Control-Allow-Headers CORS header if the API lives in another domain than the web app itself.

HTTP cache can help improve the performance and reduce the ecological footprint of a web app. The main drawback is that responses are cached based on their request signature. The cached responses for GET https://api.acme.com/posts and GET https://api.acme.com/posts/123 live in separate buckets on the client-side, and cannot be shared. As a consequence, the browser still makes a lot of useless requests to the API. HTTP cache also has another drawback: browser caches ignore the REST semantics. That means that a call to DELETE https://api.acme.com/posts/123 can't invalidate the cache of the GET https://api.acme.com/posts request, and therefore the cache is sometimes wrong.

These shortcomings explain why most APIs adopt short expiration delays, or use "validation caching" (based on Etag or Last-Modified headers) instead of "expiration caching" (based on the Cache-Control or Expires headers). But with validation caching, the client must send every request to the server (sometimes the server returns an empty response, letting the client know that it can use its cache). Validation caching reduces network traffic a lot less than expiration caching and has less impact on performance.

Finally, if your API uses GraphQL, it probably doesn't offer HTTP caching.

Waterfall by 
Kristy Kravchenko

Application Cache (New!)

React-admin v3.3 comes with its own caching system, called application cache, to overcome the limitations of the HTTP cache. This cache is opt-in - you have to enable it by including validity information in the dataProvider response. Let's see how it works.

React-admin already stores responses from the dataProvider in the Redux store, for the optimistic rendering (see above). The application cache checks if this data is still valid, and skips the call to the dataProvider altogether if it's the case.

For instance, if the end-user displays a list of posts, then clicks on a post in the list to display the list details, here is what react-admin does:

  1. Display the empty List
  2. Call dataProvider.getList('posts'), and store the result in the Redux store
  3. Re-render the List with the data from the Redux store
  4. When the user clicks on a post, display immediately the post from the Redux store (optimistic rendering)
  5. Check that the post of id 123 is still valid, and as it's the case, end here

If you compare that scenario with the one of optimistic rendering, you can see that application cache prevents a second API call.

The application cache uses the semantics of the dataProvider verbs. That means that requests for a list (getList) also populate the cache for individual records (getOne, getMany). That also means that write requests (create, udpate, updateMany, delete, deleteMany) invalidate the list cache - because after an update, for instance, the ordering of items can be changed.

So the application cache uses expiration caching together with a deeper knowledge of the data model, to allow longer expirations without the risk of displaying stale data. It especially fits admins for API backends with a small number of users (because with a large number of users, there is a high chance that a record kept in the client-side cache for a few minutes may be updated on the backend by another user). It also works with GraphQL APIs.

Configuring Application Cache

To enable application cache, the dataProvider response must include a validUntil key, containing the date until which the record(s) is (are) valid.

// response to getOne('posts', { id: 123 })
{
    "data": { "id": 123, "title": "Hello, world" }
+   "validUntil": new Date('2020-03-02T13:24:05')
}

// response to getMany('posts', { ids: [123, 124] }
{
    "data": [
        { "id": 123, "title": "Hello, world" },
        { "id": 124, "title": "Post title 2" },
    ],
+   "validUntil": new Date('2020-03-02T13:24:05')
}

// response to getList('posts')
{
    "data": [
        { "id": 123, "title": "Hello, world" },
        { "id": 124, "title": "Post title 2" },
        ...

    ],
    "total": 45,
+   "validUntil": new Date('2020-03-02T13:24:05')
}

To empty the cache, the dataProvider can simply omit the validUntil key in the response.

Tip: As of writing, the validUntil key is only taken into account for getOne, getMany, and getList.

It's your responsibility to determine the validity date based on the API response, or based on a fixed time policy.

For instance, to have a dataProvider declare responses for getOne, getMany, and getList valid for 5 minutes, you can wrap it in the following proxy:

// in src/dataProvider.js
import simpleRestProvider from 'ra-data-simple-rest';

const dataProvider = simpleRestProvider('http://path.to.my.api/');

const cacheDataProviderProxy = (dataProvider, duration =  5 * 60 * 1000) =>
    new Proxy(dataProvider, {
        get: (target, name: string) => (resource, params) => {
            if (name === 'getOne' || name === 'getMany' || name === 'getList') {
                return dataProvider[name](resource, params).then(response => {
                    const validUntil = new Date();
                    validUntil.setTime(validUntil.getTime() + duration);
                    response.validUntil = validUntil;
                    return response;
                });
            }
            return dataProvider[name](resource, params);
        },
    });

export default cacheDataProviderProxy(dataProvider);

Tip: As caching responses for a fixed period of time is a common pattern, react-admin exports this cacheDataProviderProxy wrapper, so you can write the following instead:

// in src/dataProvider.js
import simpleRestProvider from 'ra-data-simple-rest';
import { cacheDataProviderProxy } from 'react-admin'; 

const dataProvider = simpleRestProvider('http://path.to.my.api/');

export default cacheDataProviderProxy(dataProvider);

Benchmarking The Application Cache

Let's consider a simple scenario:

  1. the user displays a list of posts
  2. the user clicks on the first post of the list and displays the post detail
  3. the user clicks on the "Posts" menu to display the list again.

Without application cache, the browser calls the dataProvider at each step. With application cache, the browser only needs to call the dataProvider at step 1.

Here is how that impacts to the browser performance, recorded using Chrome DevTools in the react-admin simple example:

Without cacheWith cache
without cache with cache

The difference lies in the "scripting" section. In this scenario, the application cache offers a 38% reduction in scripting time, which translates to a much smoother navigation for the end user.

We've done other benchmarks, showing various scripting time gains (between 15% and 50%). Your mileage may vary of course, but the more long-lived your admin is, the bigger the benefit of the application cache.

How much CO2 does application cache save on this scenario? Well, that's the big unknown. Depending on the server technology, the response size, the network distance between the client and the server, and the energy source used to power the datacenter, the network relays, and the client, this can vary by one or two orders of magnitude. In my estimate, it won't compensate for the last steak you ate, but it's significant. I'll let you figure it out by reading Danny van Kooten's calculation of CO2 emissions on the web.

Conclusion

Application cache provides a very significative boost for the end-user and saves a large portion of the network traffic. Even a short expiration date (30 seconds or one minute) can speed up a complex admin with a low risk of displaying stale data. Adding an application cache is, therefore, a warmly recommended practice!

Application cache ships with react-admin 3.3.0. Isn't that a great reason to upgrade? Not to mention the other changes (see the 3.3.0 changelog) and the fact that it's backwards compatible...

So, let's all reduce the carbon footprint of our apps and make the world a better place!

Did you like this article? Share it!