Skip to content
academia.sh

Lesson 03 / 19

Static Site Generation

Producing markup at build time and writing it to a file; the same template called at two separate moments, build duration that grows linearly with the page count, and static output's freshness limit.

Contents

Server-side rendering accepts doing the same work on every request. For the station’s archive pages, this work is pure repetition: a day’s measurement records do not change once that day closes, and the same input always produces the same markup. Instead of repeating on every request a computation that gives the same output, it is possible to do it once and store the result.

This lesson makes that shift: the work moves from the moment a request arrives to build time. What gets measured is what this gains for time to first byte, and where the cost gets written.

The Template Stays the Same, the Moment It Is Called Changes

For the comparison to be meaningful, both paths must use the same template. The template and the data source are put in a separate module; both the builder and the server call it.

// page.mjs — the archive page's data source and template; both callers use this
export const wait = (ms) => new Promise((c) => setTimeout(c, ms));

// Daily measurement record: derived deterministically from the day, the data source is a stand-in.
export function dailyMeasurements(day) {
  return Array.from({ length: 24 }, (_, hour) => ({
    time: String(hour).padStart(2, "0") + ":00",
    temperature: (-8 + ((day * 7 + hour * 3) % 130) / 10).toFixed(1),
    humidity: 40 + ((day * 11 + hour * 5) % 55),
  }));
}

export function page(day, measurements) {
  const rows = measurements
    .map((m) => `<tr><td>${m.time}</td><td>${m.temperature}</td><td>${m.humidity}</td></tr>`)
    .join("");
  return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>North Slope - day ${day}</title></head>
<body><h1>North Slope Measurement Station</h1><h2>Day ${day}</h2>
<table><thead><tr><th>Time</th><th>Temperature</th><th>Humidity</th></tr></thead>
<tbody>${rows}</tbody></table></body></html>
`;
}

This is what makes static generation a calling-moment decision rather than a technique: nothing in the code changes, only when the page function runs changes.

Production at Build Time

The builder walks the list of pages to produce, and for each one calls the template and writes the result to disk.

// build.mjs — builds the archive pages at build time and measures the duration
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dailyMeasurements, page } from "./page.mjs";

const OUTPUT = "output";

function build(pageCount) {
  rmSync(OUTPUT, { recursive: true, force: true });
  mkdirSync(OUTPUT, { recursive: true });
  const start = performance.now();
  let bytes = 0;
  for (let day = 1; day <= pageCount; day++) {
    const body = page(day, dailyMeasurements(day));
    writeFileSync(`${OUTPUT}/day-${day}.html`, body);
    bytes += Buffer.byteLength(body);
  }
  return { duration: performance.now() - start, bytes };
}

build(50); // warm-up run: not part of the measurement

console.log("pages".padStart(7) + "time (ms)".padStart(12) + "per page (ms)".padStart(19) +
  "output (KB)".padStart(13));
let previousDuration = null, previousCount = null;
for (const n of [100, 500, 1000, 5000]) {
  const { duration, bytes } = build(n);
  let ratio = "";
  if (previousDuration !== null) {
    ratio = `   pages x${(n / previousCount).toFixed(0)} -> time x${(duration / previousDuration).toFixed(1)}`;
  }
  console.log(String(n).padStart(7) + duration.toFixed(1).padStart(12) +
    (duration / n).toFixed(3).padStart(19) + (bytes / 1024).toFixed(0).padStart(13) + ratio);
  previousDuration = duration; previousCount = n;
}

The node build.mjs command runs in the same directory as this file, and writes an output directory next to it.

  pages   time (ms)      per page (ms)  output (KB)
    100         5.2              0.052          137
    500        28.2              0.056          686   pages x5 -> time x5.4
   1000        52.2              0.052         1373   pages x2 -> time x1.8
   5000       293.3              0.059         6872   pages x5 -> time x5.6

Durations depend on the machine, disk speed, and load at the time; they come out different on every run. The column that matters is the last one: when the page count grows fivefold, the duration also grows roughly fivefold. The build is linear, and the per-page cost stays constant.

At this scale, the per-page cost goes more into writing files than into processing the template. The sign of this is that the measured ratio climbs slightly above five as the page count grows: as directory entries multiply, file system work does not get cheaper.

Two Paths, the Same Body

The same page can be served from two sources and compared: the file on disk, and the template called at request time.

// static-server.mjs — serves the same page two ways: a file on disk, and on-demand production
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { wait, dailyMeasurements, page } from "./page.mjs";

const HTML = "text/html; charset=utf-8";

createServer(async (request, response) => {
  response.sendDate = false;
  const staticMatch = request.url.match(/^\/static\/day-(\d+)\.html$/);
  const onDemandMatch = request.url.match(/^\/on-demand\/day-(\d+)$/);
  let body;
  if (staticMatch) {
    body = await readFile(`output/day-${staticMatch[1]}.html`, "utf8");
  } else if (onDemandMatch) {
    await wait(40); // work of reading the day's records from the data source
    body = page(Number(onDemandMatch[1]), dailyMeasurements(Number(onDemandMatch[1])));
  } else {
    return response.writeHead(404).end();
  }
  response.setHeader("Content-Type", HTML);
  response.setHeader("Content-Length", Buffer.byteLength(body));
  response.writeHead(200).end(body);
}).listen(8173, "127.0.0.1", () => console.log("listening: 127.0.0.1:8173"));
#!/usr/bin/env bash
# First builds the pages, then compares the two paths with the same measure.
node build.mjs > /dev/null   # produces the pages; its table is run separately
node static-server.mjs > /dev/null &
server=$!
sleep 1
curl -sS -o /dev/null http://127.0.0.1:8173/static/day-1.html   # warm-up

FORMAT='  first byte %{time_starttransfer} s   body %{size_download} B\n'

echo "--- file on disk ---"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8173/static/day-7.html
echo "--- on-demand production ---"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8173/on-demand/day-7

echo "--- are the two bodies the same? ---"
curl -sS http://127.0.0.1:8173/static/day-7.html > a.html
curl -sS http://127.0.0.1:8173/on-demand/day-7 > b.html
if cmp -s a.html b.html; then echo "same"; else echo "different"; fi
rm -f a.html b.html

kill "$server"
--- file on disk ---
  first byte 0.000862 s   body 1398 B
--- on-demand production ---
  first byte 0.042864 s   body 1398 B
--- are the two bodies the same? ---
same

The bodies are identical byte for byte; the times to first byte differ by two orders of magnitude — 0.0009 seconds versus 0.0429 seconds. What reaches the user has not changed; producing it has been moved backward in time.

The source of the difference is the data source’s 40-millisecond work. On the static path, this work was done once, at build time, and is never done at request time. In on-demand production, it is redone on every request: a thousand visitors means the same computation a thousand times.

What Static Output Brings

Being a file on disk carries consequences that come from being a file.

It requires no server runtime. The response is a file read; it can be served without an application process, a data source connection, or a runtime environment. This means a content delivery network can copy the output as is and serve it.

The response is the same for everyone. The same address gives every client the same bytes; this lets the response be stored with a long lifetime in shared layers. The distinction from the Caching Strategies lesson sits at its most favorable side here: content that never changes is content that does not even need a validation request.

Load becomes independent of request volume. In server-side rendering, cost grew with the number of requests; in static generation, cost grows with the number of pages and is paid once. The difference between ten visitors and ten thousand visitors is only bandwidth.

The failure surface narrows. If there is no code running at request time, there is no failure that can arise at request time either. A template error surfaces during the build and is caught before it reaches production.

Where It Cannot Be Applied

Static generation has a single condition: a page’s output must depend only on inputs known at build time. This condition breaks down in three situations.

If a page varies per user, it cannot be generated this way. The station operator’s panel is different for each person; at build time, it is not known who it is being produced for.

If content changes between builds, the output goes stale. The live measurement list changes every minute; a file built once a day can never report the age of the value it shows. In static generation, freshness is bounded by build frequency.

If the page count grows past what a build can carry, the method jams. The measured linearity looks like good news, but linear growth has an end: 0.05 milliseconds per page means five seconds at a hundred thousand pages, and over a minute at a million pages. In a real build, the per-page work is larger than in this example, and rebuilding the entire site for a single typo fix becomes unacceptable.

This third limit leads to a question that arises from within static generation itself: is it possible to refresh only what changed, without regenerating every page?

Summary

  • Static generation calls the template that produces markup at build time, not at request time; the template itself does not change.
  • The measurement shows the two paths’ bodies are identical byte for byte, while their times to first byte differ by two orders of magnitude; the difference is the data source’s work being moved earlier in time.
  • Build duration grows linearly with the page count, and the per-page cost stays constant; total cost depends on the page count, not on request volume.
  • Static output requires no runtime, gives everyone the same response, and can be cached with a long lifetime; because no code runs at request time, the failure surface narrows.
  • The method cannot be applied if the output depends on inputs unknown at build time, if content changes between builds, or if the page count outgrows what a build can carry.

Next Step

The third limit suggests there is a place between the two. A page can be generated statically without having to be treated as valid forever: it can be given a lifetime, and when that lifetime expires, the next request can receive the old copy while a new version is produced in the background. This keeps the runtime cost low without condemning freshness to the build’s frequency. The next lesson builds and measures this mechanism: the freshness window, the background revalidation queue, and the hit and miss counts.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close