Boost the UX of your React app with hash based content caching

What problem are we trying to solve?

Say you have a JavaScript app that gets served up at http://mysite.com/js/myapp.js. A typical performance optimization is to tell the browser to cache myapp.js so that the user doesn't have to re-download the asset every time they use the app. If you practice continuous delivery, the problem you run into is delivering new app updates. If myapp.js is cached, the user won't get the new updates until either a) they clear their cache or b) the max-age expires.

From the google dev docs:

Ideally, you should aim to cache as many responses as possible on the client for the longest possible period, and provide validation tokens for each response to enable efficient revalidation.

What we're going to do in this guide is we're going to come up with a way to cache our application assets for the longest possible time: FOREVER! Well sort of.. we are going to be using a hash based content caching strategy, which the google dev docs mentions it gives you the best of both worlds: client-side caching and quick updates.

Getting started with create-react-app

So to get started, we are going to use good ole create react app to quickly standup a new single page application.

Let's create a new app, create-react-app content-cache

So in a new directory, ~/code, lets run this:

npx create-react-app content-cache
cd content-cache

So now you'll have a new app setup in ~/code/content-cache and you should now be in the content-cache directory.

Now we can run npm run build which will output all the assets for your app in ./build. With these assets now available, let's take a look at serving these with nginx.

nginx + docker = yayyyyyy

Let's go ahead and create a new file, touch ~/code/content-cache/Dockerfilewith the following contents:

FROM nginx:1.13-alpine

RUN apk add --no-cache bash curl

COPY nginx/ /

CMD [“/docker-entrypoint.sh”, “nginx”, “-g”, “daemon off;”]

EXPOSE 8080

COPY build/static/ /usr/share/nginx/html/

COPY package.json /

You’ll notice we are missing a few things:

  • The nginx/folder being copied.
  • The docker-entrypoint.sh script.

Let’s go ahead and add those now.

Create a new directory, mkdir -p ~/code/content-cache/nginx/etc/nginx and then create a new file touch ~/code/content-cache/nginx/etc/nginx/nginx.conf.

Then open up the file and copy the following contents into it:

user  nginx;
worker_processes 1;

error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
worker_connections 1024;
}

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

sendfile        on;

keepalive_timeout  65;

gzip  on;
gzip_types text/plain application/xml application/javascript text/css;

include /etc/nginx/conf.d/*.conf;

}

Most of this is boilerplate nginx config, so I am not going to spend time explaining it, you can learn more from the nginx docs. Just note that we are including /etc/nginx/conf.d/*.conf, which includes the default.conf file, we’ll be creating next.

Let’s go ahead and create the file, touch ~/code/content-cache/nginx/etc/nginx/conf.d/default.conf and add the following contents to it:

server {
listen 8080;

# include the hash based content
include /etc/nginx/conf.d/app/*.conf;

location ~ ^/$ {
    # we are serving the app at `/a/`
    return 303 a/;
}

# serve other static assets
location / {
    root   /usr/share/nginx/html;
    index  /index.html;
    try_files $uri /index.html;
    include /etc/nginx/conf.d/app/preload.headers;
}

}

We are going to be serving the app at /a/, which is a strategy used to make life a bit easier when dealing with reverse proxying to backend APIs that live on the same domain.

So again, make note that we are including /etc/nginx/conf.d/app/*.conf;, which is our hash based content.

Now let’s move on to creating a new file touch ~/code/content-cache/nginx/docker-entrypoint.sh where the magic happens.

Paste in the following contents:

#!/usr/bin/env bash

mkdir -p /etc/nginx/conf.d/app
pushd /usr/share/nginx/html/js/ > /dev/null

APP_JS=/app/js/app.js
for js in main...js
do
cat > /etc/nginx/conf.d/app/js.conf <<EOF
location ~* ^/app/js/main.js([.]map)?$ {
expires off;
add_header Cache-Control “no-cache”;
return 303 ${js}$1;
}
location ~* ^/app/js/(main[.][a-z0-9][a-z0-9][.]js(?:[.]map)?)$ {
alias /usr/share/nginx/html/js/$1;
expires max;
add_header Cache-Control “public; immutable”;
}
EOF
APP_JS=“/js/${js}”
break;
done
RUNTIME_JS=/app/js/runtime.js
for js in runtime~main.
.js
do
cat > /etc/nginx/conf.d/app/js.conf <<EOF
location ~* ^/app/js/runtime~main.js([.]map)?$ {
expires off;
add_header Cache-Control “no-cache”;
return 303 ${js}$1;
}
location ~* ^/app/js/(runtime~main[.][a-z0-9][a-z0-9][.]js(?:[.]map)?)$ {
alias /usr/share/nginx/html/js/$1;
expires max;
add_header Cache-Control “public; immutable”;
}
EOF
RUNTIME_JS=“/js/${js}”
break;
done
VENDOR_JS=/app/js/vendor.js
for js in 2.
..js
do
cat >> /etc/nginx/conf.d/app/js.conf <<EOF
location ~
^/app/js/2[.]js([.]map)?$ {
expires off;
add_header Cache-Control “no-cache”;
return 303 ${js}$1;
}
location ~* ^/app/js/(2[.][a-z0-9][a-z0-9]*[.]js(?:[.]map)?)$ {
alias /usr/share/nginx/html/js/$1;
expires max;
add_header Cache-Control “public; immutable”;
}
EOF
VENDOR_JS=“/js/${js}”
break;
done

cd …/css
APP_CSS=/app/css/main.css
for css in main...css
do
cat > /etc/nginx/conf.d/app/css.conf <<EOF
location ~* ^/app/css/main.css([.]map)?$ {
expires off;
add_header Cache-Control “no-cache”;
return 303 ${css}$1;
}
location ~* ^/app/css/(main[.][a-z0-9][a-z0-9]*[.]css(?:[.]map)?)$ {
alias /usr/share/nginx/html/css/$1;
expires max;
add_header Cache-Control “public; immutable”;
}
EOF
APP_CSS=“/css/${css}”
done

cd …

cat > /etc/nginx/conf.d/app/preload.headers <<EOF
add_header Cache-Control “public; must-revalidate”;
add_header Link “<${APP_CSS}>; rel=preload; as=style; type=text/css; nopush”;
add_header Link “<${VENDOR_JS}>; rel=preload; as=script; type=text/javascript; nopush”;
add_header Link “<${APP_JS}>; rel=preload; as=script; type=text/javascript; nopush”;
add_header X-Frame-Options “SAMEORIGIN” always;
EOF

cat > index.html <<EOF
<!DOCTYPE html>
<html lang=“en”>
<head>
<meta charset=“UTF-8”/>
<meta name=“viewport” content=“width=device-width, initial-scale=1.0”/>
<meta http-equiv=“X-UA-Compatible” content=“ie=edge”/>
<title>Create React app</title>
<link href=“${APP_CSS}” rel=“stylesheet”>
</head>
<body>
<div id=“root”></div>
<script type=“text/javascript” src=“${VENDOR_JS}”></script>
<script type=“text/javascript” src=“${APP_JS}”></script>
<script type=“text/javascript” src=“${RUNTIME_JS}”></script>
</body>
</html>
EOF

popd > /dev/null

exec “$@”

Let’s go ahead and break this down bit by bit.

mkdir -p /etc/nginx/conf.d/app
pushd /usr/share/nginx/html/js/ > /dev/null

This creates a new directory and uses pushd to cd into the /usr/share/nginx/html/js directory, while redirecting the output to /dev/null so the console doesn’t get noisy.

APP_JS=/a/js/app.js
for js in main...js
do
cat > /etc/nginx/conf.d/app/js.conf <<EOF

This is a for loop, which iterates over the javascript files matching main...js, which is the pattern for our hashed content files. It then concatenates the location blocks into a file /etc/nginx/conf.d/app/js.conf.

location ~* ^/a/js/main.js([.]map)?$ {
expires off;
add_header Cache-Control “no-cache”;
return 303 ${js}$1;
}

We also are redirecting any requests to /a/js/main.js to the matching hash based filed.

location ~* ^/a/js/(main[.][a-z0-9][a-z0-9][.]js(?:[.]map)?)$ {

Also notice we are matching .map files so that we can load source map files as well.

    alias   /usr/share/nginx/html/js/$1;

Then we are caching those hash based files to the MAX!

    expires max;
add_header Cache-Control “public; immutable”;
}
EOF

We then store the hashed asset file in APP_JS so we can use that later in the script.

    APP_JS=“/js/${js}”
break;
done

The next three for loops do the same as above, but for the different asset files. The runtime files runtime~main..js, the vendor files 2...js, and the css files main...css.

Next we set our preload.headers.

cat > /etc/nginx/conf.d/app/preload.headers <<EOF
add_header Cache-Control “public; must-revalidate”;
add_header Link “<${APP_CSS}>; rel=preload; as=style; type=text/css; nopush”;
add_header Link “<${VENDOR_JS}>; rel=preload; as=script; type=text/javascript; nopush”;
add_header Link “<${APP_JS}>; rel=preload; as=script; type=text/javascript; nopush”;
add_header X-Frame-Options “SAMEORIGIN” always;
EOF

This tells the browser to preload these assets and store these files in the http cache. We specify nopush so that the server knows we only want to preload it for now.

We then dynamically create our index.html file:

cat > index.html <<EOF
<!DOCTYPE html>
<html lang=“en”>
<head>
<meta charset=“UTF-8”/>
<meta name=“viewport” content=“width=device-width, initial-scale=1.0”/>
<meta http-equiv=“X-UA-Compatible” content=“ie=edge”/>
<title>Create React app</title>
<link href=“${APP_CSS}” rel=“stylesheet”>
</head>
<body>
<div id=“root”></div>
<script type=“text/javascript” src=“${VENDOR_JS}”></script>

We use the APP_JS variable to set the src for our js file. We also do the same for the other asset files.

    <script type=“text/javascript” src=“${APP_JS}”></script>
<script type=“text/javascript” src=“${RUNTIME_JS}”></script>
</body>
</html>
EOF

Then we change back to the original directory with popd > /dev/null and then execute any args passed to this script exec “$@”. That’s important otherwise the args after the “/docker-entrypoint.sh” will not work in our Dockerfile command: CMD [“/docker-entrypoint.sh”, “nginx”, “-g”, “daemon off;”].

Let’s see it all in action

We’re going to build and run the Docker container.

In ~/code/content-cache, run:

  • chmod +x ./nginx/docker-entrypoint.sh - make the script executable.
  • docker build -t nginx/test . - this builds the image.
  • docker run --name=“nginx-test-app” -p 8080:8080 nginx/test - this runs the docker container.

Now that your app is running, head to http://localhost:8080. Open up the network tab in your dev tools and refresh the page. You should see the JavaScript and CSS assets should now be getting cached. It should look something like this:

Looking good! Now let’s do another build just to make sure it is working as intended. Kill the current docker container by pressing ctr + c and then running docker rm nginx-test-app.

Now run npm run build && docker build -t nginx/test . then docker run --name=“nginx-test-app” -p 8080:8080 nginx/test, open up http://localhost:8080 and checkout the network tab to confirm that the asset files are from the latest build.

🤘Now we’re talking! At this point now, we have the best of both worlds setup: Max content caching and quick updates when a new version of our app is deployed.

Feel free to use this technique and modify to fit your own needs. The link to the repo is below.

Resources:

Thanks for reading. If you liked this post, share it with all of your programming buddies!

Further reading

☞ React - The Complete Guide (incl Hooks, React Router, Redux)

☞ Modern React with Redux [2019 Update]

☞ Best 50 React Interview Questions for Frontend Developers in 2019

☞ JavaScript Basics Before You Learn React

☞ Microfrontends — Connecting JavaScript frameworks together (React, Angular, Vue etc)

☞ Reactjs vs. Angularjs — Which Is Best For Web Development

☞ React + TypeScript : Why and How

☞ How To Write Better Code in React

☞ React Router: Add the Power of Navigation

☞ Getting started with React Router

☞ Using React Router for optimizing React apps


This post was originally published here

#reactjs #react-native #javascript #docker #mobile-apps

Boost the UX of your React app with hash based content caching
2 Likes26.85 GEEK