Client-Side Scalability

Rendering Techniques


Learning Objectives

  • You know the different rendering techniques used in web applications, including server-side rendering (SSR), client-side rendering (CSR), static site generation (SSG), and hybrid rendering.
  • You know of the benefits and disadvantages of each rendering approach.

Here, we look the different rendering techniques used in web applications, including server-side rendering (SSR), client-side rendering (CSR), static site generation (SSG), and hybrid rendering. We’ll explore the advantages and considerations of each approach, along with small examples.

Note that the examples here are intentionally simplified to illustrate the core concepts. A bit later, we’ll look into a framework for rendering.

Server-Side Rendering (SSR)

Server-side rendering generates a fully rendered HTML page on the server for each request. This typically involves fetching data from a data source, creating an HTML page, and sending it to the client. As an example, we could have an application that fetches a list of items from a database and renders them into an HTML list.

Below, the application creates one thousand items and renders them into a list on an HTML page. The page is then returned as a response to the client. A 20 millisecond delay is added to the data fetching to simulate a scenario where the data is retrieved e.g. from a database.

import { Hono } from "@hono/hono";
import { cors } from "@hono/hono/cors";

const app = new Hono();
app.use(cors());

const getItems = async () => {
  await new Promise((resolve) => setTimeout(resolve, 20));
  const items = Array.from(
    { length: 1000 },
    (_, i) => ({ id: i, name: `Item ${i}` }),
  );
  return items;
};

app.get("/ssr", async (c) => {
  const items = await getItems();

  return c.html(`<html>
    <head>
    </head>
    <body>
      <ul>
        ${items.map((item) => `<li>${item.name}</li>`).join("")}
      </ul>
    </body>
  </html>`);
});

export default app;

To test the server-side rendering, we can use a browser script that opens a page and checks if the last item is visible. This simulates a user visiting the page and verifying that the content is rendered correctly.

Using localhost as the address reflects the use of Traefik in the Walking Skeleton as outlined in the chapter on Traffic Distribution and Load Balancing.

import { browser } from "k6/browser";

export const options = {
  scenarios: {
    client: {
      vus: 5,
      duration: "20s",
      executor: "constant-vus",
      options: {
        browser: {
          type: "chromium",
        },
      },
    },
  },
};

export default async () => {
  const page = await browser.newPage();
  await page.goto("http://localhost:8000/ssr");

  try {
    await page.locator(`//li[text()="Item 999"]`).isVisible();
  } finally {
    await page.close();
  }
};

Running the above tests, we get the following results:

browser_data_sent........: 25 kB 1.2 kB/s
browser_web_vital_cls....: avg=0.000982 min=0        med=0        max=0.009471 p(90)=0.005682 p(95)=0.009471
browser_web_vital_fcp....: avg=98.32ms  min=73.89ms  med=96ms     max=145ms    p(90)=114.83ms p(95)=131.06ms
browser_web_vital_lcp....: avg=98.32ms  min=73.89ms  med=96ms     max=145ms    p(90)=114.83ms p(95)=131.06ms
browser_web_vital_ttfb...: avg=936.29µs min=500µs    med=899.99µs max=2.39ms   p(90)=1.26ms   p(95)=1.53ms
data_received............: 0 B   0 B/s
data_sent................: 0 B   0 B/s
iteration_duration.......: avg=332.62ms min=288.92ms med=333.62ms max=392.73ms p(90)=357.34ms p(95)=368.56ms
iterations...............: 135   6.530656/s
vus......................: 5     min=5      max=5
vus_max..................: 5     min=5      max=5

There is no interactivity on the site, so interaction to next paint is not relevant, and there is practically no shift in the layout as the page is simple. The relevant metric is largest (and first) contentful paint, which is on average 98ms.

When considering server-side rendering, there are both benefits and downsides. Responding with the full page content can help with search engine optimization (SEO), as search engine crawlers can index the content without the need for e.g. executing scripts on the page. However, server-side rendering can be more demanding on the server than handling the rendering on the client. It can also lead to slower initial page loads, especially if the server is under heavy load or the rendering process is complex.

Loading Exercise...

Client-Side Rendering (CSR)

Client-side rendering shifts the rendering responsibility to the browser. Initially, a minimal HTML shell is retrieved along with essential JavaScript. Once the JavaScript is executed on the browser, it dynamically fetches the data from the server and renders the content.

To concretely illustrate this, create a folder called public to the folder server, and then create a file called csr.html to the server/public/ folder. Place the following contents to the file.

<html>
  <head>
    <script>
      document.addEventListener("DOMContentLoaded", async () => {
        const list = document.getElementById("list");
        const items = await fetch("http://localhost:8000/items");
        const json = await items.json();
        for (const item of json) {
          const li = document.createElement("li");
          li.textContent = item.name;
          list.appendChild(li);
        }
      });
    </script>
  </head>
  <body>
    <ul id="list"></ul>
  </body>
</html>

The above site fetches items from the server and renders them into a list on the page. The fetching happens when the HTML has been parsed and the DOM has been constructed. This is a common pattern in client-side rendering, where the initial HTML is minimal and the content is fetched and rendered dynamically.

The corresponding server-side functionality is as follows. We add a middleware for serving static files from the public folder, and create an endpoint for fetching the items.

// ...
import { serveStatic } from "@hono/hono/deno";
// ...

app.use("/public/*", serveStatic({ root: "." }));

app.get("/items", async (c) => {
  const items = await getItems();
  return c.json(items);
});

The above changes illustrate the key concept in client-side rendering: the server sends minimal HTML shell with JavaScript. The client then interprets the JavaScript, retrieves the data, and renders the data for the user.

Next, modify the browser test, changing the path from which the page is loaded. Instead of using the path ssr, we’ll retrieve the page from /public/csr.html.

export default async () => {
  const page = await browser.newPage();
  await page.goto("http://localhost:8000/public/csr.html");

  try {
    await page.locator(`//li[text()="Item 999"]`).isVisible();
  } finally {
    await page.close();
  }
};

The results from running the performance test are as follows. There is very little difference in the performance metrics compared to server-side rendering.

browser_data_sent........: 27 kB 1.3 kB/s
browser_web_vital_cls....: avg=0.000772 min=0        med=0        max=0.009471 p(90)=0        p(95)=0.009471
browser_web_vital_fcp....: avg=102.43ms min=69.7ms   med=100.69ms max=150.7ms  p(90)=122.67ms p(95)=135.6ms
browser_web_vital_lcp....: avg=102.43ms min=69.7ms   med=100.69ms max=150.7ms  p(90)=122.67ms p(95)=135.6ms
browser_web_vital_ttfb...: avg=983.7µs  min=500µs    med=899.99µs max=4ms      p(90)=1.35ms   p(95)=1.76ms
data_received............: 0 B   0 B/s
data_sent................: 0 B   0 B/s
iteration_duration.......: avg=343.11ms min=293.62ms med=341.19ms max=422.67ms p(90)=378.15ms p(95)=390.65ms
iterations...............: 135   6.543022/s
vus......................: 5     min=5      max=5
vus_max..................: 5     min=5      max=5

Like server-side rendering, client-side rendering has also its benefits and downsides. The main advantage is enhanced user interactions with smooth transitions (i.e., if there is dynamic content, the client can fetch it without reloading the full page). Similarly, as the thin HTML shell is static, even if it would have plenty of JavaScript, it can be cached to improve performance. There are also limitations, however, such as potential delays in displaying initial content and SEO limitations (search engines may not fully index JavaScript-generated content).

Loading Exercise...

Static Site Generation (SSG)

Static Site Generation (SSG) pre-renders pages into static HTML files during a build process. These files are then served directly from the server, without the need to generate the content on each request. SSG is ideal for content that does not change frequently, such as blogs or documentation sites.

For simplicity, we can create a file called ssg.html and place it in the server/public/ folder. The file would contain the plain HTML document with the list of items — if you wish to try this out, you can e.g. copy-paste the HTML output from the /ssr endpoint to the ssg.html file.

<html>
  <head>
  </head>
  <body>
    <ul>
      <li>Item 0</li>
      <li>Item 1</li>
      ...
    </ul>
  </body>
</html>

With the static file in place, we do not need to modify the server code, as there is already a middleware for serving static files. To test this, we can modify the browser test to load the file at /public/ssg.html instead of the client- or server-rendered page.

export default async () => {
  const page = await browser.newPage();
  await page.goto("http://localhost:8000/public/ssg.html");

  try {
    await page.locator(`//li[text()="Item 999"]`).isVisible();
  } finally {
    await page.close();
  }
};

When testing the performance results of the static site generation, we get the following metrics. The difference to server-side rendering and client-side rendering is small. This mainly stems from the simplicity of the example.

browser_data_sent........: 27 kB 1.3 kB/s
browser_web_vital_cls....: avg=0.000627 min=0        med=0        max=0.009471 p(90)=0        p(95)=0.009471
browser_web_vital_fcp....: avg=99.96ms  min=67.89ms  med=98.95ms  max=152.6ms  p(90)=119.55ms p(95)=128.9ms
browser_web_vital_lcp....: avg=99.96ms  min=67.89ms  med=98.95ms  max=152.6ms  p(90)=119.55ms p(95)=128.9ms
browser_web_vital_ttfb...: avg=871.32µs min=500µs    med=899.99µs max=2ms      p(90)=1.19ms   p(95)=1.32ms
data_received............: 0 B   0 B/s
data_sent................: 0 B   0 B/s
iteration_duration.......: avg=329.26ms min=249.79ms med=327.84ms max=427.87ms p(90)=370.24ms p(95)=384.84ms
iterations...............: 136   6.609263/s
vus......................: 5     min=5      max=5
vus_max..................: 5     min=5      max=5

In general, static site generation can be more cost-effective than server-side rendering or client-side rendering, as the pages are pre-rendered and served directly from the server. As the pages are static, they can also be cached easily, which can further improve performance. However, static site generation is less suitable for highly dynamic applications, as the pages need to be rebuilt upon data updates.

Loading Exercise...

Hybrid Rendering

Hybrid Rendering combines the previous, rendering a part of the page on the server, and the rest on the client. This approach aims to balance the benefits of server-side rendering and client-side rendering. The initial page load is fast, as the server sends a fully rendered HTML page. The client then takes over and fetches the dynamic content, updating the page as needed.

This could look, for example, as follows. Below, there are four new functions — two for the data, and two API endpoints. Now, when the page is loaded, the initial items are fetched and rendered from the server, while the remaining items are fetched from the /items/remaining endpoint.

const getInitialItems = async () => {
  await new Promise((resolve) => setTimeout(resolve, 20));
  return Array.from({ length: 100 }, (_, i) => ({ id: i, name: `Item ${i}` }));
};

const getRemainingItems = async () => {
  await new Promise((resolve) => setTimeout(resolve, 20));
  return Array.from(
    { length: 900 },
    (_, i) => ({ id: i + 100, name: `Item ${i + 100}` }),
  );
};

app.get("/items/remaining", async (c) => {
  const items = await getRemainingItems();
  return c.json(items);
});

app.get("/hybrid", async (c) => {
  const items = await getInitialItems();

  return c.html(`<html>
    <head>
      <script>
        document.addEventListener("DOMContentLoaded", async () => {
          const list = document.getElementById("list");
          const items = await fetch("http://localhost:8000/items/remaining");
          const json = await items.json();
          for (const item of json) {
            const li = document.createElement("li");
            li.textContent = item.name;
            list.appendChild(li);
          }
        });
      </script>
    </head>
    <body>
      <ul id="list">
        ${items.map((item) => `<li>${item.name}</li>`).join("")}
      </ul>
    </body>
  </html>`);
});

Performance-wise, the performance of the hybrid rendering is similar to the server-side rendering and client-side rendering. The initial content is rendered on the server, and the dynamic content is fetched and rendered on the client. The main advantage of hybrid rendering is the balance between fast initial load times and dynamic interactivity. However, it requires careful state management to avoid hydration mismatches and can increase development complexity due to the blend of server- and client-side rendering.

export default async () => {
  const page = await browser.newPage();
  await page.goto("http://localhost:8000/hybrid");

  try {
    await page.locator(`//li[text()="Item 999"]`).isVisible();
  } finally {
    await page.close();
  }
};

The performance results for the hybrid rendering are as follows:

browser_data_sent........: 26 kB 1.3 kB/s
browser_web_vital_cls....: avg=0.000631 min=0        med=0        max=0.009471 p(90)=0        p(95)=0.009471
browser_web_vital_fcp....: avg=101.53ms min=63.2ms   med=99.6ms   max=147.39ms p(90)=125.21ms p(95)=133.2ms
browser_web_vital_lcp....: avg=101.53ms min=63.2ms   med=99.6ms   max=147.39ms p(90)=125.21ms p(95)=133.2ms
browser_web_vital_ttfb...: avg=931.85µs min=399.99µs med=899.99µs max=2.79ms   p(90)=1.21ms   p(95)=1.5ms
data_received............: 0 B   0 B/s
data_sent................: 0 B   0 B/s
iteration_duration.......: avg=330.57ms min=270.91ms med=328.31ms max=414.84ms p(90)=361.84ms p(95)=385.88ms
iterations...............: 135   6.648828/s
vus......................: 5     min=5      max=5
vus_max..................: 5     min=5      max=5

Again, the differences in terms of the metrics are rather small.

Dual penalty, but not actually..

Note, however, that the hybrid approach gets penalized twice from the delay in fetching the items, as both the initial items and the remaining items are fetched with a delay of 20 milliseconds. Despite this, the difference in the metrics is negligible.

The reason for this is that the metrics focus on the initial rendering, which is not affected by the delay in fetching the remaining items that are way below what the user initially sees. The user does not need to wait for the second fetch to see the initial content, and thus, the delay from fetching the subsequent items does not show in the performance metrics.

Hybrid rendering balances benefits of server-side rendering and client-side rendering. It enables fast initial load times and dynamic interactivity, making it suitable for progressive hydration where the initial load is fast, and JavaScript functionality is activated progressively. Depending on the data that is rendered, the initially rendered content could also be cached to improve performance. However, with a more complex application, hybrid rendering requires additional effort in managing the state and the rendering process.

Loading Exercise...