---
title: 'Network Performance'
source: 'https://academia.sh/en/courses/frontend-quality/network-performance'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# Network Performance

Separating freshness from validation, measuring cache headers on a local server, the immutability promise of content-hashed names, and what connection hints actually earn.

The previous three lessons dealt with the document and the assets themselves: which
file, how big, when it gets requested. One more layer exists, and there a gain can get
achieved without reducing a single byte. Does the same user opening the station list a
second time have to redownload the same files?

The answer to this question lies not in the request but in the headers the response
carries. The Caching Strategies lesson in the Browser and the Web Platform course took up
program-controlled caching; the subject here is what the server determines with headers
alone, without a program getting written.

## Freshness and Validation Are Two Separate Decisions

The HTTP cache answers two separate questions, and confusing them is the most common
cause of misconfiguration.

**Freshness lifetime** answers this question: how long can this response be used
without a query? The `max-age` directive in the `Cache-Control` header gives a duration;
until the duration expires, the browser **makes no request at all**. The cheapest request
is the one never made.

The **validator** answers this question: is the copy I have still valid? The `ETag`
header carries a signature tied to the content. Once the freshness lifetime expires, the
browser sends this signature back in an `If-None-Match` header; if the content has not
changed, the server returns a response with no body.

The two decisions complement each other. Freshness prevents the request entirely;
validation makes the request cheap. The `no-cache` directive leads to a
misunderstanding: it does not forbid storage, it only requires validation before every
use. The directive that forbids storage is `no-store`.

## Measuring the Headers

The following server serves three resources under three separate policies and genuinely
handles the conditional request.

```js
// cache-server.mjs — shows freshness and validation headers on three separate resources
import { createServer } from "node:http";
import { createHash } from "node:crypto";

const bodies = {
  "/assets/station.9f2c1a.css": {
    type: "text/css",
    content: ".station-list{display:grid;gap:12px}",
    // Content-hashed name: if the address changes, the content has changed.
    cache: "public, max-age=31536000, immutable",
  },
  "/api/stations": {
    type: "application/json",
    content: JSON.stringify({ stations: ["north-slope-1", "north-slope-2"] }),
    // Gets validated on every use; the body only gets sent if it changed.
    cache: "no-cache",
  },
  "/api/announcement": {
    type: "application/json",
    content: JSON.stringify({ announcement: "Maintenance window: 03:00-04:00" }),
    // Fresh for one minute; stale served and revalidated in the background for the next five.
    cache: "public, max-age=60, stale-while-revalidate=300",
  },
};

const tag = (content) =>
  `"${createHash("sha256").update(content).digest("hex").slice(0, 16)}"`;

createServer((req, res) => {
  res.sendDate = false;
  const resource = bodies[req.url];
  if (!resource) {
    res.writeHead(404, { "Content-Type": "text/plain" });
    return res.end("not found\n");
  }

  const validator = tag(resource.content);
  const headers = {
    "Cache-Control": resource.cache,
    ETag: validator,
    Vary: "Accept-Encoding",
  };

  // Conditional request: the body does not get sent if the client's validator is still valid.
  if (req.headers["if-none-match"] === validator) {
    res.writeHead(304, headers);
    return res.end();
  }

  res.writeHead(200, {
    ...headers,
    "Content-Type": resource.type,
    "Content-Length": Buffer.byteLength(resource.content),
  });
  res.end(resource.content);
}).listen(8241, "127.0.0.1", () => console.log("listening: 127.0.0.1:8241"));
```

```bash
#!/usr/bin/env bash
# Starts cache-server.mjs, measures freshness and validation behavior, stops it.
node cache-server.mjs > /dev/null &
server=$!
sleep 1
base=http://127.0.0.1:8241

for path in /assets/station.9f2c1a.css /api/stations /api/announcement; do
  printf '%s\n' "$path"
  curl -sS -o /dev/null -D - "$base$path" \
    | grep -iE '^(HTTP/|cache-control|etag|content-length)' \
    | tr -d '\r' | sed 's/^/  /'
done

printf '\n--- conditional request ---\n'
tag=$(curl -sS -o /dev/null -D - "$base/api/stations" \
  | awk 'tolower($1) == "etag:" { print $2 }' | tr -d '\r')
printf 'validator held by the client: %s\n' "$tag"

curl -sS -o /dev/null -H "If-None-Match: $tag" "$base/api/stations" \
  -w 'conditional request   -> status %{http_code}, downloaded body %{size_download} bytes\n'
curl -sS -o /dev/null "$base/api/stations" \
  -w 'unconditional request -> status %{http_code}, downloaded body %{size_download} bytes\n'

kill "$server"
```

```
/assets/station.9f2c1a.css
  HTTP/1.1 200 OK
  Cache-Control: public, max-age=31536000, immutable
  ETag: "f4c1b262ab403423"
  Content-Length: 36
/api/stations
  HTTP/1.1 200 OK
  Cache-Control: no-cache
  ETag: "be211c47597a1001"
  Content-Length: 46
/api/announcement
  HTTP/1.1 200 OK
  Cache-Control: public, max-age=60, stale-while-revalidate=300
  ETag: "510bd98694f649be"
  Content-Length: 50

--- conditional request ---
validator held by the client: "be211c47597a1001"
conditional request   -> status 304, downloaded body 0 bytes
unconditional request -> status 200, downloaded body 46 bytes
```

The validator values are stable in this output because they get produced from a digest
of the content; they change when the content changes. Port 8241 is an arbitrary choice
and must be free.

The measured difference is clear: the conditional request returns with no body. The
round trip still happened — a conditional request does not eliminate latency, only
transfer. Freshness lifetime eliminates the round trip too. This difference between the
two determines which policy gets written on which resource.

## The Immutable Asset, the Changing Document

The `immutable` directive makes a promise: the content at this address will never
change. The promise can only be kept if the address gets derived from the content. The
cache-busting approach in the Rendering Strategies and Infrastructure course provides
exactly that: when a content hash gets written into the file name, the address changes
whenever the content changes, and the old address staying fresh forever creates no
problem.

The document cannot make this promise, because its address is fixed and its content
changes with every release. The correct policy for a document is validation, not a long
freshness period.

For the case in between, there is the `stale-while-revalidate` directive: after the
freshness period expires, the old copy gets served immediately for a specified duration
while the refresh happens in the background. The user does not wait; the data refreshes
with one round trip's delay. It suits content that tolerates delay, like an announcement
banner; it does not suit content like an account balance.

How many bytes each of these three policies transfers on a repeat visit can be computed.

```js
// repeat-visit.mjs — how header policy affects bytes transferred on a repeat visit

const HEADER_OVERHEAD = 320; // bytes: the approximate share of request + response headers

// The station list page's resources and their selected policy.
const resources = [
  { name: "document",      bytes:  18_400, policy: "no-cache" },
  { name: "style",         bytes:  62_000, policy: "immutable" },
  { name: "entry script",  bytes: 148_112, policy: "immutable" },
  { name: "shared script", bytes: 101_760, policy: "immutable" },
  { name: "font",          bytes:  41_600, policy: "immutable" },
  { name: "card image",    bytes:  86_400, policy: "immutable" },
  { name: "station data",  bytes:   9_240, policy: "max-age=60" },
];

const fullSize = (k) => HEADER_OVERHEAD + k.bytes;
const firstVisit = resources.reduce((t, k) => t + fullSize(k), 0);

// Bytes transferred for one resource on a repeat visit.
function repeatBytes(resource, elapsedSeconds, changed) {
  const hasChanged = changed.has(resource.name);
  switch (resource.policy) {
    case "immutable":
      // Content-hashed name: if it changed, the address has changed, and the new resource downloads from scratch.
      return hasChanged ? fullSize(resource) : 0;
    case "no-cache":
      // Gets validated on every use: if unchanged, only the headers arrive.
      return hasChanged ? fullSize(resource) : HEADER_OVERHEAD;
    case "max-age=60":
      if (elapsedSeconds <= 60) return 0;             // still fresh: no request
      return hasChanged ? fullSize(resource) : HEADER_OVERHEAD;
    default:
      return fullSize(resource);
  }
}

const scenarios = [
  { name: "after 30 seconds, same version", elapsed: 30, changed: new Set() },
  { name: "after 300 seconds, same version", elapsed: 300, changed: new Set() },
  {
    name: "new version published",
    elapsed: 300,
    changed: new Set(["document", "style", "entry script"]),
  },
];

console.log(`first visit: ${(firstVisit / 1024).toFixed(1)} KB`);
for (const s of scenarios) {
  console.log(`\n--- ${s.name} ---`);
  console.log("resource".padEnd(14) + "repeat".padStart(10) + "  policy");
  let total = 0;
  for (const k of resources) {
    const b = repeatBytes(k, s.elapsed, s.changed);
    total += b;
    console.log(k.name.padEnd(14) + `${(b / 1024).toFixed(2)} KB`.padStart(10) + "  " + k.policy);
  }
  console.log("-".repeat(36));
  console.log("TOTAL".padEnd(14) + `${(total / 1024).toFixed(2)} KB`.padStart(10));
  console.log(`ratio to first visit: ${(total / firstVisit).toFixed(4)}`);
}

console.log("\nif no freshness header had been written, every repeat visit ratio would be 1.0000.");
```

```
first visit: 458.7 KB

--- after 30 seconds, same version ---
resource          repeat  policy
document         0.31 KB  no-cache
style            0.00 KB  immutable
entry script     0.00 KB  immutable
shared script    0.00 KB  immutable
font             0.00 KB  immutable
card image       0.00 KB  immutable
station data     0.00 KB  max-age=60
------------------------------------
TOTAL            0.31 KB
ratio to first visit: 0.0007

--- after 300 seconds, same version ---
resource          repeat  policy
document         0.31 KB  no-cache
style            0.00 KB  immutable
entry script     0.00 KB  immutable
shared script    0.00 KB  immutable
font             0.00 KB  immutable
card image       0.00 KB  immutable
station data     0.31 KB  max-age=60
------------------------------------
TOTAL            0.63 KB
ratio to first visit: 0.0014

--- new version published ---
resource          repeat  policy
document        18.28 KB  no-cache
style           60.86 KB  immutable
entry script   144.95 KB  immutable
shared script    0.00 KB  immutable
font             0.00 KB  immutable
card image       0.00 KB  immutable
station data     0.31 KB  max-age=60
------------------------------------
TOTAL          224.41 KB
ratio to first visit: 0.4892

if no freshness header had been written, every repeat visit ratio would be 1.0000.
```

The resource sizes are the computation's input, not the output of a measured
application; the header share is also an assumption. What gets computed is the policy's
consequence.

In the first two scenarios, the repeat visit is nearly free. The third scenario teaches
the real lesson: when a new version gets published, only the parts that **changed**
download again. Because the shared script did not change, its address did not change
either, and it never gets requested. This is where bundling decisions have a performance
consequence — if frequently changing code and rarely changing code get grouped into the
same chunk, every release redownloads both.

## Vary and the Cache Key

If a response varies according to request headers, this must get declared with the
`Vary` header. Left undeclared, intermediate caches serve a single copy to everyone: a
compressed response can go to a client that does not support compression, or a page
produced for one language can go to a user in another.

The rule is a correctness rule, and it affects performance too: adding an unnecessary
header to the `Vary` list splits the cache key more than it needs to be and lowers the
hit rate. If a user-specific header enters the list, the cache is effectively disabled.

## Connection Hints

Connection hints written into the document tell the browser to do a job early. Each one
moves a different stage earlier.

`dns-prefetch` performs only name resolution. It is the cheapest hint and earns the
least.

`preconnect` performs name resolution, the transport connection, and the secure
handshake ahead of time. It spends the rounds counted as "connection setup" in the
critical-path model before the resource even gets requested. It has a cost: a connection
that gets established and goes unused holds resources on both the client and the server,
so it should get written only for origins that will definitely be used.

`preload` requests a specific resource early. As described in the Critical Rendering
Path lesson, this is a correction: it moves a late-discovered critical resource earlier.
The `as` attribute is required; written incorrectly, the resource downloads twice.

`prefetch` downloads, at idle priority, a resource that will be needed on the **next**
navigation. Its priority is the lowest, and it is wasted bandwidth if the user never goes
to that page.

The order is a priority order: flatten the chain first, then `preconnect`, and `preload`
last. A hint written without measurement often does harm by taking bandwidth away from
the critical resource.

## Summary

- Freshness lifetime prevents the request entirely, the validator makes the request
  bodiless; the two solve separate problems, and `no-cache` forbids not storage but
  unvalidated use.
- A content-hashed address is the condition for the `immutable` promise to be kept; a
  document with a fixed address cannot make this promise and gets managed through
  validation.
- In a new release, only the parts whose address changed download again; a bundling
  decision is therefore a caching decision.
- The `Vary` header is a correctness matter before it is a performance matter: its
  absence serves the wrong response, its excess splits the cache.
- Connection hints move different stages earlier and each one has a cost; a hint written
  without measurement cuts off the critical resource.

## Next Step

Once the network side is arranged, bytes arrive on time and a repeat visit makes almost
no request. The application can still feel heavy, though: the list stutters while
scrolling, a letter typed into the filter box appears on screen late, the page may freeze
for a moment when the date range changes. None of these problems are in the network; all
of them come from downloaded code running in a single queue. The next lesson takes up
this queue: it computes how long tasks get measured, what chunking earns, and how layout
thrashing scales with row count.
