Plucking Some Low-Hanging Fruit of Spring Boot Web Performance
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 a full 2.9 MB in size, all served uncompressed 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:
Properties fileserver.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%. (It was still a large React application.)
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 URIs, 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:
Java@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 URIs under /assets/js
, strip the hash, then look for the corresponding
resources under /static/assets/js
in the classpath. The URI
/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):
Javaimport 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:
HTML<script th:src="@{/js/bundle.js}"></script>
Which takes care of using the correct URIs. 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:
JavaScriptdevServer: {
// …
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!
- I abhor websites using JavaScript to deliver static content, but this is not one of those.↩
- The bundle is still accessible at
bundle.js
without cache headers but we never request it directly.↩