DEV Community

loading...

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

matsilva profile image Mat Silva ・8 min read

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/Dockerfile with 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:

hashed content in network tab

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.

latest build assets in network tab

🤘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:

Credits:

  • @connolly_s - showed me the light with this strategy 🤣

Discussion (0)

pic
Editor guide