DEV Community

Cover image for A way to speed up Next.js dynamic SSR
Pavel Krasnov
Pavel Krasnov

Posted on

A way to speed up Next.js dynamic SSR

Let's say you have a React server component that fetches data on a server and renders a list of items:

import PokemonList from "./PokemonList";

async function fetchPokemon(id: number) {
    const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
    return response.json();
}

const pokemonIds = Array
    .from({ length: 20 })
    .map((_item, index) => index + 1);

export default async function Home() {
    const pokemons = await Promise.all<any>(pokemonIds.map(item => fetchPokemon(item)));

    return <PokemonList pokemons={pokemons} />;
}
Enter fullscreen mode Exit fullscreen mode

You need the item list to be a client component for some reason:

"use client";

import PokemonItem from "./PokemonItem";

type Props = {
    pokemons: any[];
}

export default function PokemonList({ pokemons }: Props) {
    return (
        <ul>
            {
                pokemons.map((item, index) => <PokemonItem key={index} pokemon={item} />)
            }
        </ul>
    )
}
Enter fullscreen mode Exit fullscreen mode

As client components are also executed on a server, you will get the same code running twice on both the server and the client. But have you ever thought about what really happens when you execute the code above?

Next.js way to share server state with client

When you pass props from a server component to a client in Next.js, it implicitly serializes the props and appends them to the HTML document. Then, on a client, it deserializes your props and uses them in the component they are passed to.

Server state passed as props to a client component in the resulting HTML document
Server state passed as props to a client component in the resulting HTML document

The implicit overhead

But aren't the props serialized one time more than needed? When you query your API for JSON data using fetch, you usually call the json() method on the response body. When you pass the data to a client component from the server, Next.js implicitly calls JSON.stringify() on them. Then isomorphic JavaScript in a client component runs twice - on a server and on a client, but though the data is already parsed on a server, Next.js has to implicitly call JSON.parse() on them on a client. Let's count it: parse + stringify on a server and parse on a client. Once again, isn't the data stringified one more time than needed?

Fixing the issue

The only thing we actually need to fix things is to remove the redundant serialization on a server. The response body also has other methods to read the stream. If we read it to a string by calling text() instead of json() and therefore not deserialize the JSON by calling JSON.parse(), we will still be able to pass the response string to a client component, deserialize it there, and use it without losing anything. We would still parse the data on a server and parse it on a client, but we wouldn't stringify it on a server!

This is how the "fixed" components might look:

import PokemonList from "./PokemonList";

async function fetchPokemon(id: number) {
    const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
    return response.text();
}

const pokemonIds = Array
    .from({ length: 20 })
    .map((_item, index) => index + 1);

export default async function Home() {
    const pokemonsStringArray = await Promise.all<string>(pokemonIds.map(item => fetchPokemon(item)));
    const pokemonsString = `[${pokemonsStringArray.join(",")}]`;

    return <PokemonList pokemons={pokemonsString} />;
}
Enter fullscreen mode Exit fullscreen mode

The code above still queries the API for the list of items, but this time we read the response body to a string and then concatenate all the strings, making a JSON array from it. Then we pass the string to a client component.

"use client";

import PokemonItem from "./PokemonItem";

type Props = {
    pokemons: string;
}

export default function PokemonList({ pokemons }: Props) {
    const pokemonObjects = JSON.parse(pokemons) as any[];

    return (
        <ul>
            {
                pokemonObjects.map((item, index) => <PokemonItem key={index} pokemon={item} />)
            }
        </ul>
    )
}
Enter fullscreen mode Exit fullscreen mode

The client component needs to explicitly do the job it would do implicitly if we passed the data as an object. This code runs on both the server and the client.

Note about static generation

If you just create an app like this, Next.js will make all required requests at build time and generate static pages. If this is enough for you, you might not need to do the thing described in this article in your app. It happens though that you need to dynamically render pages on every request. Dynamically rendered pages, which do their data requests on a server, are the biggest beneficiaries of the described technique. The most common scenario that will result in dynamic rendering is using Next.js dynamic functions. For the sake of simplicity, I use the force-dynamic segment config option in the example below.

Note about caching

As of now (June 2024), Next.js by default caches fetch requests made on the server. During local development, the cache is stored in files in the .next/cache/fetch-cache folder. But even if no actual network request is made, the response body is still deserialized using JSON.parse() if you call the json() method on a response body.

Note about data transformations

Sometimes you don't need to just pass the data fetched on a server to a client component. You would want to apply some computations to them instead and only then send them to a client. Consider the following example:

const pokemons = await Promise.all<any>(pokemonIds.map(item => fetchPokemon(item)));

const filteredPokemons = pokemons.filter(item => item.height > 5);
Enter fullscreen mode Exit fullscreen mode

In this case, it is necessary to parse the data on a server to process it according to your needs. You might do it in a client component instead:

"use client";

import PokemonItem from "./PokemonItem";

type Props = {
    pokemons: string;
}

export default function PokemonList({ pokemons }: Props) {
    const pokemonObjects = JSON.parse(pokemons) as any[];
    const filteredPokemons = pokemonObjects.filter(item => item.height > 5);

    return (
        <ul>
            {
                pokemonObjects.map((item, index) => <PokemonItem key={index} pokemon={item} />)
            }
        </ul>
    )
}
Enter fullscreen mode Exit fullscreen mode

This is totally ok, but be aware that in this case the work gets done on both server and client, which may slow down your app's client-side performance. It is a trade-off, and you should decide which part of the app has to be optimized.

How faster my application would be?

Feel free to clone the repo I created as an illustration for the issue. Let's run some tests together:

  1. Run npm run build to build a production version of the app;
  2. Run npm start to run a local Node.js server that will serve your app;
  3. Open http://localhost:3000/slow in a browser to make sure Next.js creates a filesystem data cache and the first testing request doesn't send any actual network requests;
  4. Install oha - a tool we will use to send requests to the server;
  5. Run oha http://localhost:3000/slow. It will send 200 requests through 50 parallel connections to the server.
  6. Stop the server and remove the .next folder to make sure there is no cached data;
  7. Repeat steps 1–5, but this time use http://localhost:3000/fast.

My Macbook Pro M1 powered by Asahi Linux gives the following results:

  • slow version:

Slow page results: around 7 requests per second

  • fast version:

Fast page results: around 18 requests per second

By making one simple change, we made the app ~2.5 times faster.

Afterword

This technique is used in the production version of the app I am currently working on: Czech TV schedule. Of course, in a complex app that does a lot of other work on the server, the effect will be more modest; in our case, it made the app around 30% faster. The need to speed up the SSR of a standalone build of this app led me to the development of a number of techniques, which I am going to share with you in this blog.

Top comments (0)