Skip to content

Architecture decisions

  • Lesson 16
  • 65 min
  • Module 5: Performance and scalability

What you will be able to do

Kwame asks whether the desk should be drawn on a server instead of in the browser. Compare the three ways to make a page (client rendering, server rendering and static pages) by what the product needs. Write the decision down with its reasons, and say when you would change it.

Before you startWhat to know and to have readyAbout 65 minutes

What you need to know

What you need on your computer

  • Your project after the last lesson: npm test prints pass 21 and Tests 43 passed (43), and npm run build makes four JavaScript files.
  • The mock API running in a first terminal (npm run api).

This lesson ends with the practice for the whole module, about 25 minutes of it. Tested with Node.js 24.18.0 on macOS (Apple silicon), in Chromium 156.

There are three ways to get a dinner. A meal kit arrives at your door as a box of ingredients and a recipe card. The box comes fast, but you do the cooking, and the meal is ready only when you are done. In return, you can cook it your way, again and again, without asking anyone.

In a restaurant, the kitchen cooks your order and brings it ready to eat. You eat sooner, but the kitchen works for every single order. When the restaurant is full, everyone waits for the kitchen.

A sandwich made in the morning is ready before anyone asks. It is handed over at once and costs almost nothing per customer. But it is the same sandwich for everybody, and it is only as fresh as the morning.

Web pages are made in the same three ways. The question is never “which is best?”. It is “what does this product need?”

Kwame, the solution architect, opens the desk’s production files and finds dist/index.html. This is all of it, 394 bytes:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ticket desk</title>
<script type="module" crossorigin src="/assets/index-DnbxJFGT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cq7Ckq9E.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

“The body is one empty div,” he says. “Without JavaScript, the page shows nothing. A search engine sees nothing. On Omar’s phone, nothing useful appears until 93 kB of JavaScript have arrived and run. Big sites draw their pages on the server. Shouldn’t we?”

His facts are right. On the phone-like profile of the last lesson, FCP came at 872 ms and first rows at 1,102.1 ms. Almost all the time before FCP was the download of the JavaScript. But “big sites do it” is not a reason. A choice like this is an architecture decision. It is hard to change later, so you make it on purpose, from the product’s needs, and you write it down.

Rendering means turning data and components into the HTML that the screen shows. The three choices differ in where and when that happens.

  1. Client rendering. The server sends a small HTML file and the JavaScript. The browser, which is the client, runs the components, asks the API for data and builds the page. This is the meal kit, and it is how the desk works now.
  2. Server rendering. For every request, a server runs the same components with the data and sends HTML that already holds the content. Then the JavaScript arrives and attaches its event handlers to that HTML, so buttons and forms start to work. This step has a name: hydration. The HTML is like dried food, and the JavaScript is the water that brings it to life. This is the restaurant.
  3. Static pages. The HTML of every page is made once, before anyone asks, by a build. Any simple web server or content delivery network (CDN) hands out the files. This is the sandwich made in the morning. The page that you are reading is one: Nextia Learning builds its lessons as static pages.
Three rows, one per choice, each a sequence of steps for one visit. Client rendering (the ticket desk): the server sends a small HTML file and JavaScript, the browser runs JavaScript, asks the API, draws; content after the browser has done everything. Server rendering (an order status page): the server gets the data and writes the HTML, HTML with content, then JavaScript, and the page works; content when the HTML arrives, buttons work later. Static pages (a help centre): HTML written once, before anyone asks, in a build; HTML with content; content when the HTML arrives, no work per visit.
The order of events for one visit in each choice. Not to scale: the course built only the client-rendered desk, so only its times are measured.
Text description of the diagram

The diagram has three rows. In client rendering, the server sends a small HTML file and the JavaScript, the browser runs the JavaScript, asks the API and draws. The content appears only after the browser has done all of it. In server rendering, the server first gets the data and writes the HTML. The HTML arrives with the content in it. Then the JavaScript arrives, and the page starts to work. In static pages, a build wrote the HTML once, before anyone asked. It arrives with the content, and nothing is done per visit. The lengths of the steps are not to scale.

Real products also mix them. A static frame of the page can fetch its data in the browser. A server can render the first screen and let the browser do every later screen. The three plain choices are enough to reason about the mixes.

A tiny example: one ticket list, three ways

Section titled “A tiny example: one ticket list, three ways”

Take the first two rows of the ticket list. Here is what the server sends in each choice.

Client rendering sends the 394 bytes above. The rows exist only after the browser has run the JavaScript and the API has answered.

Server rendering sends the rows inside the HTML. A server program asks the ticket API for this agent’s first page and writes, for example:

<ul class="rows">
<li><a class="row" href="#/tickets/T-80010">… Return label for my order LK-300210 …</a></li>
<li><a class="row" href="#/tickets/T-80009">… My order LK-55190 never arrived …</a></li>
</ul>

The agent sees the rows when the HTML arrives, before any JavaScript runs. But the server must run React for every request, and the links work only after hydration.

Static pages would write the same HTML once, in a build. But which tickets? The ones of the moment of the build. A ticket that arrives one minute later is not in the page. And every agent would see the same page, including tickets that are not theirs to see. For the desk’s live, private data, a static page does not work at all.

Client rendering Server rendering Static pages
Who makes the HTML The browser, for every visit A server, for every request A build, once
Content visible After the JavaScript and the API When the HTML arrives When the HTML arrives
Without JavaScript Nothing Content, but buttons do not work Content
Work on a server per visit None (only the API) Run the components None
Fresh data Yes, from the API Yes, at request time Only as fresh as the last build
What you must run Static files and the API A server that runs JavaScript, and the API Static files

Sam and Kwame go through the desk’s needs, one by one:

  • Who uses it? Larkfield’s agents, signed in, on their work computers. They open the desk in the morning and keep it open all day. The first load happens once a day; every later screen change is fast, because the code is already in the browser.
  • Must search engines see it? Never. Tickets hold customers’ personal data. A page that a search engine could read would be a leak.
  • How fresh is the data? It changes every minute, and each agent sees different tickets. A static page cannot hold it.
  • What is cheapest to run? Client rendering: the files are static, so any web server or CDN can serve them. Only the API runs code.

So the desk stays rendered in the browser. Omar’s phone complaint is real, but the last two lessons answered it more cheaply: a smaller first file and a virtual list. Server rendering would add a second server that runs JavaScript for every request and must be kept secure. All that would save part of one load per day.

Kwame then asks about two other pages that Larkfield plans. The flowchart is the reasoning for all three:

A flowchart. Question: must people without the app, or search engines, see the content? No: signed-in users, private data, leads to client rendering, the ticket desk. Yes leads to a second question: is the content the same for everyone, and does it change slowly? Yes leads to static pages, a help centre. No, per person or every minute, leads to server rendering, an order status page.
Two questions from the product, three answers for three of Larkfield's pages.
Text description of the diagram

The flowchart asks two questions. First: must people without the app, or search engines, see the content? If not, because the users are signed in and the data is private, choose client rendering, as for the ticket desk. If yes, ask the second question: is the content the same for everyone, and does it change slowly? If yes, choose static pages, as for a help centre. If it is different per person, or changes every minute, choose server rendering, as for an order status page.

  • A public help centre with articles for customers (“How do I return an item?”). Everyone sees the same articles, they change a few times a week, and customers must find them with a search engine. Static pages: built when an article changes, served from a CDN, fast on any phone.
  • An order status page, opened from a link in an email, mostly on phones. Each customer sees their own order, and it changes during the day. The content should appear at once, without a large download. Server rendering is a fair choice here, with care: the page holds personal data, so no shared cache may keep it. The failure case below shows why.
  • Client rendering fits tools that people sign in to and keep open: dashboards, editors, the ticket desk. It is the cheapest to run. It costs a slower first load, especially on phones, and an empty page without JavaScript.
  • Server rendering fits pages that must show personal or fast-changing content at once, often on slow devices. It costs a server that runs JavaScript for every request, with its own scaling, monitoring and security. Hydration also needs the same JavaScript as client rendering, so the download does not disappear; it only stops blocking the first view.
  • Static pages fit content that is the same for everyone and changes slowly: documentation, help centres, marketing pages, this course. They are the fastest and cheapest. They cost a new build for every change, and they cannot show private or live data without client code.

Whatever you choose, write it down. An architecture decision record is a short document. It names the choice, the reasons, the costs that you accept, and the conditions that would change it. Six months later, nobody has to guess why the desk works this way.

Guided practice: write the decision record

Section titled “Guided practice: write the decision record”
  1. In ~/projects/ticket-desk, make a folder docs.

  2. In it, make a file rendering-choices.md with three parts. First, a table of the three choices: what the server sends, what each is good for, what each costs. Then Our decision, with the reasons. Last, When we would change it.

  3. For each reason, write the fact that supports it. Use your own measurements where you have them: the size of the first file from npm run size, and first rows on the phone-like profile.

  4. Compare your file with the course’s version: ../nextia-learning-labs/C22/snapshots/end-of-m05/docs/rendering-choices.md.

  5. Look at what the desk shows without JavaScript. Run npm run build and npm run preview, and open http://localhost:4173/. In DevTools, open the command menu (Ctrl+Shift+P, or Cmd+Shift+P on a Mac), type Disable JavaScript, press Enter, and reload.

  6. Turn JavaScript on again: in the command menu, run Enable JavaScript.

  7. Copy the last files of this module from the snapshot. The README now lists npm run size, npm run build:profile and the docs/ folder, and package.json and the lock file get the version 0.5.0:

    Terminal window
    S=../nextia-learning-labs/C22/snapshots/end-of-m05
    cp "$S/README.md" "$S/package.json" "$S/package-lock.json" .

    Your project now has the same files as the snapshot end-of-m05, except your own words in docs/rendering-choices.md.

Show the expected result

Step 4: the course’s decision record has the same three parts. Its decision reads:

The ticket desk is rendered in the browser (Vite builds static files; the browser draws the screens).
- Its users are signed-in agents who keep it open all day. The first load happens once a day;
after that, every screen change is fast, because the code is already in the browser.
- Search engines must never see tickets. Server rendering for search is not needed.
- The data changes every minute and is different for each agent: a static page cannot hold it.
- The files are static, so any web server or CDN can serve them. Only the API runs code.

and ends with When we would change it. A public page that must appear fast on slow phones and be found by search engines gets server rendering or static pages, as a separate site. The desk stays as it is. Your words can differ. Check that each reason is a fact about the product, not a fashion.

Step 5: a white page. The tab’s title is still “Ticket desk”, because the title is in the HTML. Everything else comes from JavaScript.

Predict first, then decide. Larkfield wants a public page “Is the help desk busy?”. It shows the expected wait for a reply, updated every 5 minutes, the same for every visitor. Many customers open it on phones, and it must be found by a search for “Larkfield support”. Which choice, and what does it cost? Write the decision in four lines, as in the decision record.

Show the answer

Static pages, rebuilt every 5 minutes, or a static page with one small client request for the number. The content is the same for everyone, so there is no reason to render it per request. It must be found by search engines and appear fast on phones, so client rendering of the whole page is a poor fit. A build every 5 minutes is cheap, and a CDN serves the page fast anywhere. The cost: the number can be up to 5 minutes old, and someone must watch the job that rebuilds the page. If the number had to be live to the second, keep the static page and fetch only the number in the browser.

Common mistake

A server-rendered private page in a shared cache

This is a constructed example: the course did not build it, but the mistake is common.

Symptom: Larkfield’s order status page is rendered on the server. A CDN in front of it keeps each page for 60 seconds, to save server work. A customer opens the link in her email and sees another customer’s name and address.

Cause: the page’s address is the same for every customer, /my-order. The server finds the customer from a cookie. The CDN keeps pages by address only, so it gave the first customer’s HTML to everyone who asked in the next 60 seconds. With client rendering, the shared HTML would be the empty frame, and each browser would fetch its own data with its own sign-in. Server rendering puts the private data into the HTML, so the HTML itself becomes private.

Fix: send the header Cache-Control: private, no-store with every page that holds personal data. Cache-Control is the HTTP header that tells caches whether, and how long, they may keep an answer; private, no-store means that no shared cache may keep it. Let the CDN cache only the static files and pages that are the same for everyone. Test it: sign in as two different customers and compare the pages.

Consequence in AI work: pages with AI answers are often personal. A draft reply, a summary of a customer’s tickets, an answer from the customer’s documents: each one is private data. If the server renders such a page and a cache keeps it as if it were public, one person’s AI answer reaches another person.

Module practice: measure and improve a seeded problem

Section titled “Module practice: measure and improve a seeded problem”

This is the practice for the whole module, with its completion check. First a worked example with every number, then a task for you.

Worked example: the list that loads everything

Section titled “Worked example: the list that loads everything”

Tomás has an idea: “Agents hate to wait when they scroll. Let’s load every page at once, and draw every row, so scrolling never waits.” This version of TicketList.tsx does exactly that. It is the seeded problem.

  1. Save your own src/features/tickets/TicketList.tsx as TicketList.good.tsx in the same folder, so you can put it back.

  2. Replace the content of TicketList.tsx with Tomás’s version:

    import { useEffect, useState } from 'react';
    import type { TicketFilter } from '../../api/types';
    import { ErrorMessage } from '../../ui/ErrorMessage';
    import { useTicketList } from './queries';
    import { TicketFilters } from './TicketFilters';
    import { TicketRow } from './TicketRow';
    export const NO_FILTER: TicketFilter = { status: '', team: '', q: '' };
    /** SEEDED PROBLEM: loads every page at once and draws every row ("so that scrolling never waits"). */
    export function TicketList({ selectedId }: { selectedId: string | null }) {
    const [filter, setFilter] = useState<TicketFilter>(NO_FILTER);
    const list = useTicketList(filter);
    const tickets = list.data?.pages.flatMap((p) => p.items) ?? [];
    // Ask for the next page as soon as one arrives, until the server has no more.
    const { hasNextPage, isFetchingNextPage, fetchNextPage } = list;
    useEffect(() => {
    if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
    }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
    // A mark for the browser's performance tools: the first time that ticket rows are on the screen.
    const firstRows = tickets.length > 0;
    useEffect(() => {
    if (firstRows && performance.getEntriesByName('tickets:first-rows').length === 0) performance.mark('tickets:first-rows');
    }, [firstRows]);
    return (
    <section className="list" aria-labelledby="list-heading">
    <h2 id="list-heading">Tickets</h2>
    <TicketFilters value={filter} onChange={setFilter} />
    {list.isPending && <p className="muted">Loading tickets…</p>}
    {list.isError && <ErrorMessage error={list.error} onRetry={() => list.refetch()} />}
    {list.isSuccess && (
    <>
    <p className="muted" aria-live="polite">
    {tickets.length === 0
    ? 'No ticket matches.'
    : `Showing ${tickets.length} tickets${list.hasNextPage ? ' (more on the server)' : ''}.`}
    {list.isPlaceholderData && ' Updating…'}
    </p>
    <div className="scroller" tabIndex={0} role="region" aria-label="Ticket list">
    <ul className="rows">
    {tickets.map((t) => (
    <li key={t.ticket_id}>
    <TicketRow ticket={t} selected={t.ticket_id === selectedId} />
    </li>
    ))}
    </ul>
    </div>
    </>
    )}
    </section>
    );
    }
  3. Run npm test. Read which test fails, and why.

  4. Run npm run build and npm run preview, and open http://localhost:4173/ with the Network panel open (no throttling). Watch the line “Showing … tickets” and the number of requests at the bottom of the Network panel until both stop changing.

  5. In the Console, count the elements in the list: document.querySelectorAll('.list *').length.

  6. Put your version back: copy TicketList.good.tsx over TicketList.tsx, and delete TicketList.good.tsx. Run steps 3 to 5 again.

Show the worked example’s results

Step 3: one test fails, the test of the virtual list from the last lesson:

FAIL src/features/tickets/TicketList.test.tsx > draws only the rows in view, not every loaded ticket
AssertionError: expected 50 to be less than 25
Tests 1 failed | 42 passed (43)

The test caught the problem before any browser did: 50 loaded tickets gave 50 rows in the page.

Steps 4 and 5 and the same steps after the fix, on the course team’s laptop (median of 3 runs, no throttling):

List requests in the first load, seeded list against the desk's own list (median of 3 runs, no throttling).
Text description of the chart

In the first load, the seeded list sent 200 requests for tickets; the desk’s own list sent 1.

Measured the same way Seeded list The desk’s list (after the fix)
Requests for tickets 200 1
Time until the list stops growing 14,561 ms 109 ms
Elements in the list 60,022 131
Ticket rows in the page 10,000 18
First rows 96.6 ms 97.8 ms
Longest key press while typing “parcel” 40 ms 24 ms or less

The last row comes from the browser’s Event Timing API: the time from a key press to the next paint. It reports only presses of 16 ms or more, in steps of 8 ms.

First rows came at about the same time in both versions, so Tomás’s change does not show in that number. It shows in everything after it: 200 requests during 14.6 seconds and a page with 60,022 elements. Key presses also take longer, because each one makes React compare 10,000 rows. On a phone, the same work runs about four times slower, and the 10,000 tickets use the phone’s data. That is why a single number is never enough: measure what the agent does next too.

The fix is the list of the last lesson, with no memo and no new code. It asks for one page first, and the next page only near the end. It draws only the rows in view. The improvement is in the table: the same measurements, before and after, in the same way.

A teammate wanted the React Profiler to work in production, and changed one line of vite.config.ts:

resolve: { alias: { 'react-dom/client': 'react-dom/profiling' } },

Now every build, not only npm run build:profile, uses React’s profiling build. Your task:

  1. Make this change in your project, so you start from the seeded problem.
  2. Measure it: npm run build, npm run size, and first rows on the phone-like profile (Slow 4G, CPU 4×), three times.
  3. Fix it, without any new code other than the fix.
  4. Measure again, in the same way.
  5. Write a performance note of five lines or fewer: what you measured, how, the numbers before and after, and the fix.

Completion check. Show an observed improvement: the same measurements before and after the fix, under the same conditions. Show that you added no memo, no new library and no other code. Then explain in two sentences which of the three kinds of wait the fix changed.

Show a worked solution

Measure the seeded problem. On the course team’s laptop:

dist/assets/index-BZyjPyJL.js 319.77 kB │ gzip: 98.41 kB
assets/index-BZyjPyJL.js 312.3 KB 94.9 KB
JavaScript: 4 files, 100.8 KB gzip in all

First rows on the phone-like profile, 5 runs: median 1,124.8 ms (1,123.5 to 1,130.8).

Find the cause. The first file grew by 5.4 kB, and no new feature came with it. npm run build:profile gave the same size before the change. So every build now uses React’s profiling build. The fix is the line from Measure the browser:

resolve: mode === 'profile' ? { alias: { 'react-dom/client': 'react-dom/profiling' } } : {},

Measure again, in the same way:

dist/assets/index-DnbxJFGT.js 302.28 kB │ gzip: 93.02 kB
assets/index-DnbxJFGT.js 295.2 KB 89.7 KB
JavaScript: 4 files, 95.6 KB gzip in all

First rows on the phone-like profile, 5 runs: median 1,102.1 ms (1,094.4 to 1,123.5).

A performance note:

Problem: every production build used React's profiling build (vite.config.ts).
Measured: npm run build and npm run size; first rows, Slow 4G + CPU 4x, median of 5, preview build.
Before: index.js 98.41 kB gzip; first rows 1,124.8 ms (1,123.5-1,130.8).
After: index.js 93.02 kB gzip; first rows 1,102.1 ms (1,094.4-1,123.5).
Fix: the profiling alias only in --mode profile. No other change, no memo.

The fix changed the download: 5.4 kB less on the network. The time to first rows fell by about 23 ms, close to the spread between runs. So the size is the clear evidence, and the time only agrees with it. That is a normal result: say it as it is, and do not claim more.

Check your understanding

Kwame says: 'Server rendering is always faster, so let's server-render the desk.' What is true in his claim, and why does Larkfield still keep client rendering for the desk?

Show the answer

True: with server rendering, the content arrives in the HTML, so the first view does not wait for the JavaScript and the API. The desk keeps client rendering because of its product’s needs. Its agents are signed in and keep it open all day, so the first load happens once a day. Search engines must never see tickets. The data is private and changes every minute. Client rendering needs only static files and the API, while server rendering needs a server that runs JavaScript for every request. The first load on phones was improved more cheaply, with a smaller first file and a virtual list.

Check your understanding

Predict: Larkfield builds its help centre as static pages, and an article changes. When do customers see the change, and what must happen first?

Show the answer

Only after a new build has made the HTML again and the new files are on the server or CDN. Until then, customers see the old article. A static site needs a build for every change of content. The team runs it, or a tool starts it when an article is saved.

Check your understanding

Why is a server-rendered page with personal data riskier in a shared cache than a client-rendered page with the same data?

Show the answer

Server rendering puts the personal data into the HTML, so the HTML is private. A shared cache that keeps pages by address can give one person’s HTML to the next person. A client-rendered page’s HTML is the same empty frame for everyone, and each browser fetches its own data with its own sign-in. Pages with personal data need Cache-Control: private, no-store.

Recap

  • Client rendering sends a small HTML file and JavaScript, and the browser builds the page. Server rendering sends HTML with the content, then hydrates it. Static pages are HTML made once, by a build.
  • Choose from the product’s needs: who uses it, whether search engines must see it, how fresh and private the data is, and the cost to run it. The desk stays client-rendered; a help centre fits static pages; an order status page can be server-rendered, never in a shared cache.
  • Write the choice in a decision record, with its reasons, its costs and when you would change it.
  • Module 5 in one look: measure first, with the first-rows mark, the waterfall, the size report and the profiling build. Find the largest wait. Load and draw less: one page of data, the rows in view, the code of the open screen. Measure again in the same way. memo was never the answer here.

Next, Module 6 makes the desk safe: the browser’s security boundaries, generated Markdown, and where secrets must never go.

Next: Browser security boundaries