Blog

Engineering

Increasing the Performance of Dynamic Next.JS Websites

by Guido Maliandi on December 06 2017

One of the most obvious signs that a site is made with Next.JS is that you load the page, you click a link to another section and it loads instantly. That’s because when a page is loaded, Next.JS will download in the background all the other pages linked with tags.

This works great for static pages, but as soon as you have to fetch some data in the getInitialProps, loading that page will take time even if it the page code is prefetched. And what is worse — unless you explicitly add a loading indicator, it will usually take time with no indication in the UI!

Prefetching and caching data is not trivial — for example, it could be wrong to serve a slightly outdated version of certain information. Also, different data could have different caching rules. Here at Scale we decided to implement a lightweight caching and data prefetching strategy, which allows having this instant navigation experience without compromising data freshness.

Caching in getInitialProps

On certain parts of our site, we know it is OK to show data that is a few minutes old. For those cases, we implemented a simple caching strategy that stores data with a 5 minute TTL.
The implementation uses an abstraction around the fetch api that we called cached-json-fetch:

import lscache from 'lscache';
import fetch from 'isomorphic-fetch';

const TTL_MINUTES = 5;

export default async function(url, options) {
  // We don't cache anything when server-side rendering.
  // That way if users refresh the page they always get fresh data.
  if (typeof window === 'undefined') {
    return fetch(url, options).then(response => response.json());
  }
  
  let cachedResponse = lscache.get(url);
  
  // If there is no cached response, 
  // do the actual call and store the response
  if (cachedResponse === null) {
    cachedResponse = await fetch(url, options)
      .then(response => response.json());
    lscache.set(url, cachedResponse, TTL_MINUTES);
  }
  
  return cachedResponse;
}

export function overrideCache(key, val) {
  lscache.set(key, val, TTL_MINUTES);
}

We then use this in the getInitialProps of our pages:

import React from 'react';
import cachedFetch, { overrideCache } from 'lib/cached-json-fetch';

const SOME_DATA_URL = '/some_data';

export default class SomePage extends React.Component {
  static async getInitialProps(ctx) {
    const someData = await cachedFetch(SOME_DATA_URL);
    const isServerRendered = !!ctx.req;
    return { someData, isServerRendered };
  }

  componentDidMount() {
    // When the page is server-rendered, 
    // we override the value in the client cache
    if (this.props.isServerRendered) {
      overrideCache(SOME_DATA_URL, this.props.someData);
    }
  }
}

Now, when we client-navigate to this page, it will check the cache first before fetching the data from the server. Alternatively, on a full page reload, it will always fetch the latest data from the server and reset the TTL.

Prefetching data

With a caching layer like this in place, it is extremely straightforward to add prefetching. In the example above, all we need to do is call getInitialProps so the cache is populated with all the necessary data to load the page. Now, if this page is client-navigated to before the TTL expires, it will load instantly just like a static page!

To achieve this, we can create a simple abstraction over Link, which not only downloads the page structure in the background but also calls its getInitialProps to populate the cache:

import React from 'react';
import Link from 'next/link';
import { resolve, parse } from 'url';
import Router from 'next/router';

export default class DataPrefetchLink extends Link {
  async prefetch() {
    if (typeof window === 'undefined') {
      return;
    }

    const { pathname } = window.location;
    const href = resolve(pathname, this.props.href);
    const { query } = parse(this.props.href, true);
    const Component = await Router.prefetch(href);

    if (this.props.withData && Component) {
      const ctx = {pathname: href, query, isVirtualCall: true};
      await Component.getInitialProps(ctx);
    }
  }
}

Using this in our pages is as simple as: <Link prefetch withData href="…">.

In case you have some calls in your page’s getInitialProps that do not use a cache, you can use the isVirtualCall flag in the context to avoid making them when the method is called for caching purposes only. For example:

import React from 'react';
import cachedFetch, { overrideCache } from 'lib/cached-json-fetch';
import fetch from 'isomorphic-fetch';

const SOME_DATA_URL = '/some_data';

export default class SomePage extends React.Component {
  static async getInitialProps(ctx) {
    const someData = await cachedFetch(SOME_DATA_URL);
    const isServerRendered = !!ctx.req;
    const isVirtualCall = ctx.isVirtualCall;

    // No need to call this when prefetching the page, 
    // since this data won’t be cached
    let someNonCachedData;
    if (!isVirtualCall) {
      someNonCachedData = await fetch('/some_non_cached_data')
        .then(response => response.json());
    }

    return { someData, someNonCachedData, isServerRendered };
  }

  componentDidMount() {
    // When the page is server-rendered, 
    // we override the value in the client cache
    if (this.props.isServerRendered) {
      overrideCache(SOME_DATA_URL, this.props.someData);
    }
  }
}

The key element that makes this possible is that the programmatic prefetch API through Router.prefetch returns the page’s constructor, so we can call the getInitialProps directly on that object! You can find this extended Link component in npm as data-prefetch-link.

In conclusion, we learned that with some fine-tuning we can make our dynamic next.js pages load as fast as static pages, without sacrificing the freshness of the data or the flexibility to choose which data we want to cache. We hope this helps you make your web platforms faster!

Get started for free today