There’s a great post over at the Media Temple blog on ‘The Low Hanging Fruit of Web Performance’ :

I want to talk about low hanging fruit. Easy changes that have big impact on web performance.

[…]

The requisites to be on this list are that they are fairly easy to do and that have big performance returns.

I highly recommend reading it in full. Compression, caching, and optimization are old hat to some experienced web developers, but Turbolinks was new to me, and it never hurts to remind yourself of the basics.

Here’s a concrete example of where a little attention to detail can make a big difference. This year, I started working on the front-facing part of a Spring Boot application. A common complaint was that it was slow to load, so the first thing I did was to bring up Chrome’s dev tools and examine the requests. Sure enough, the minified bundle was 2.9 MB in size, not using server-side compression, and served with headers that forbade caching. It regularly took nearly a minute for the bundle to download, after which React would begin to replace the blank page with content.[1]

Two things needed to be fixed. The easier of the two was to enable Gzip compression in Spring. I added these lines to the properties file:

server.compression.enabled=true
server.compression.mime-types=application/json,application/xml,text/html,text/xml,text/plain,application/javascript,text/css
server.compression.min-response-size=10240

And the framework did the rest, reducing the download time by 75%.

The other change was to allow the file to be cached. Naïvely adding the required HTTP headers would have backfired: browsers would have cached the application’s code and ignored any updates. I needed to use versioned URLs , e.g. bundle-123xyz.js rather than bundle.js (where 123xyz is a hash of the contents of bundle.js). Then, any time the JavaScript bundle changed, the file would get a new hash and the HTML would change accordingly, requesting bundle-456abc.js instead, ensuring only the new code was used.

Fortunately, Spring supports doing this without changing the actual filenames, by adding VersionResourceResolver in your MvcConfig. For example:

	@Override
	public void addResourceHandlers(ResourceHandlerRegistry registry) {
		if (!registry.hasMappingForPattern("/assets/js/**")) {
			registry.addResourceHandler("/assets/js/**")
				.addResourceLocations("classpath:/static/assets/js/")
				.setCacheControl(CacheControl.maxAge(500, TimeUnit.DAYS))
				.resourceChain(false)
				.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
		}
  }

This says: for any URLs under /assets/js, strip the hash, then look for the corresponding resources under /static/assets/js in the classpath. The URL /assets/js/bundle-123xyz.js maps to the file /static/assets/js/bundle.js in the classpath, if and only if the contents of bundle.js hash to 123xyz.

Next, I had to update the HTML that included the script. We use ThymeLeaf, so I had to customize the links it generates , for which I had to customize the link builder, for which I had to add a TemplateEngineConfig file (fairly typical Java experience, I suppose):

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.resource.ResourceUrlProvider;
import org.thymeleaf.context.IExpressionContext;
import org.thymeleaf.linkbuilder.StandardLinkBuilder;
import org.thymeleaf.spring5.SpringTemplateEngine;

@Configuration
public class TemplateEngineConfig {
    @Autowired
    public void configureTemplateEngine(SpringTemplateEngine engine,
                                        ResourceUrlProvider urlProvider) {
        engine.setLinkBuilder(new VersioningLinkBuilder(urlProvider));
    }
}

class VersioningLinkBuilder extends StandardLinkBuilder {
    private final ResourceUrlProvider urlProvider;

    VersioningLinkBuilder(ResourceUrlProvider urlProvider) {
        this.urlProvider = urlProvider;
    }

    @Override
    public String processLink(IExpressionContext context, String link) {
        String lookedUpLink = urlProvider.getForLookupPath(link);
        if (lookedUpLink != null) {
            return super.processLink(context, lookedUpLink);
        } else {
            return super.processLink(context, link);
        }
    }
}

Then, in our HTML:

<script th:src="@{/js/bundle.js}"></script>

Which takes care of using the correct URLs. The setCacheControl line we included in addResourceHandlers above allows such versioned resources to be cached for up to 500 days, which is what we wanted.[2]

I also had to account for Webpack (which we use to generate the bundle). The versioning happens in Spring. Our Webpack configuration uses webpack-dev-server in development to forward everything to Spring except requests for the bundle, which it handles itself. Knowing the format of our filenames, I added this hack to the configuration to strip versions:

devServer: {
  // …
  before: (app) => {
    app.get('/assets/js/*', (req, res, next) => {
      req.url = req.url.replace(/bundle-\w+\.js/, 'bundle.js');
      next();
    });
  },
},

So, how has the user experience improved? Well, the first visit to the site took 16 seconds to download the JavaScript, as expected. On the second visit, the file was retrieved from the cache (to be precise, since it was in the same session, it was retrieved from the memory cache) instead of being downloaded. The completed request duration was shown as ‘0 ms’.

Please do read the linked article. Remember, even hundreds of milliseconds add up on the web !


  1. I abhor websites using JavaScript to deliver static content, but this is not one of those.
  2. The bundle is still accessible at bundle.js without cache headers but we never request it directly.