Medium wave · 540–1600 kHz72 stn
1465kHz

Your Static Site Has No Error Log

A vast brass alarm gallery inside a fogged cathedral of pipes, every warning bell muffled in felt and every pressure gauge frozen at zero while a cracked neon-purple conduit vents glowing vapor unnoticed behind them, dark steampunk boiler hall lit by teal filaments, dust settled thick over untouched control levers

A contact form on a client site stopped submitting. The endpoint had rotated a key, the request started coming back 403, and the page swallowed it in a catch block that showed a generic “something went wrong.”

Nobody noticed for five weeks. Not because the team was careless — because there was nothing to notice. Every page still returned 200. The CDN was healthy. Uptime was 100%. The only evidence that anything was wrong was an absence: leads stopped arriving, and absences don’t page anyone.

When you moved from a server to a static host, you deleted a process that had one underrated job. It watched every request and complained, in writing, when something went wrong. Nothing in the static stack replaced it, and most of us never noticed, because the thing we stopped receiving was bad news.


What the server was actually doing for you

A PHP app or a Rails server is loud. It is loud in ways you stopped consciously valuing because the noise was always there:

  • Exceptions with stack traces. Something threw, you got a file and a line number.
  • 5xx counts. A rate you could alarm on without writing a line of instrumentation.
  • Access logs with status codes. Every 404 recorded with its path and its Referer, which is how you found out an external site had linked to a URL you renamed.
  • Request timing. Slow endpoints surfaced themselves.

None of that was observability you designed. It was a by-product of having a machine in the request path that ran your code and wrote to disk.

A static site has no machine in the request path running your code. The CDN runs its code, and its code works fine. That is the whole problem: the one component still being monitored is the one component that wasn’t going to fail.


A 200 is not a success

This is the part that catches experienced people out. Uptime monitoring on a static site is close to useless, because the thing it measures almost never breaks.

Your edge will happily return 200 OK for:

  • A page whose hero image 404s because the file never made it into dist/.
  • A page where the JS bundle throws on line one in one browser, leaving every interactive element dead.
  • A page that renders the literal string undefined where a build-time API returned an unexpected shape.
  • A page with a <link rel="stylesheet"> pointing at a hashed filename from the previous deploy.
  • Every page, on a build that silently dropped half your content collection.

In all five cases the HTTP transaction succeeded perfectly. The bytes were delivered. What was in the bytes is not something a status check has an opinion about.

If your monitoring can only tell you whether a request completed, it will report perfect health straight through every failure mode a static site actually has.


Move detection to the build

The good news is that static architecture hands you something a server never had: a moment when the entire site exists, locally, in one directory, before any user sees it. That is where most of your error log now lives.

A lot of what a server would have caught at 3am is catchable at build time — deterministically, in seconds, with no vendor.

Assert the output, not the process. A build that “succeeded” is not evidence of anything. Check the artifact.

#!/usr/bin/env bash
set -euo pipefail

# Every post must produce a page
posts=$(find content/posts -name '*.md' | wc -l)
pages=$(find dist/posts -name 'index.html' | wc -l)
[ "$posts" -eq "$pages" ] || { echo "expected $posts post pages, got $pages"; exit 1; }

# The feed and sitemap must exist and be non-trivial
[ -s dist/rss.xml ] || { echo "missing rss.xml"; exit 1; }
[ -s dist/sitemap-index.xml ] || { echo "missing sitemap"; exit 1; }

# Nothing should ship a rendered undefined
if grep -rq '>undefined<' dist --include='*.html'; then
  echo "rendered undefined found in output"; exit 1
fi

That last check looks crude. It has caught more real bugs for me than any type system, because it inspects the thing the user receives rather than the thing the compiler agreed to.

Resolve every local reference. Broken internal links and missing images are the most common static-site defect, and they are fully decidable from dist/:

// scripts/check-refs.mjs — every local href/src must exist on disk
import { existsSync, readFileSync, globSync } from 'node:fs'
import path from 'node:path'

const broken = []

for (const file of globSync('dist/**/*.html')) {
  const html = readFileSync(file, 'utf8')
  const refs = [...html.matchAll(/(?:href|src)="(\/[^"#?]*)/g)].map((m) => m[1])

  for (const ref of refs) {
    const target = ref.endsWith('/')
      ? path.join('dist', ref, 'index.html')
      : path.join('dist', ref)
    if (!existsSync(target)) broken.push(`${file} -> ${ref}`)
  }
}

if (broken.length) {
  console.error(broken.join('\n'))
  process.exit(1)
}

Fail on bad content, don’t render it. If a post is missing a field, the build should stop rather than emit a page with a hole in it. Astro content collections and a Zod schema do this for free; the mistake is loosening fields to optional so the build stays green.

Every one of these runs in CI on every push. That is your new error log, and unlike the old one it reports before anyone is affected rather than after.


What the build cannot see

Build-time checks cover the site as a static object. They are blind to three things, and those three things are exactly where the quiet failures live.

1. The browser. Your bundle parses on your machine and in CI’s headless Chrome. It can still throw on a browser you don’t own, on a locale you didn’t try, or against an extension that mangles the DOM. A JS error on a static site produces no server-side event at all — the user sees a dead button and leaves.

2. Third-party runtime dependencies. Forms, comments, search proxies, payment widgets, anything you call from the client. These are the pieces you moved off your server precisely so you wouldn’t have to run them, which also means nobody tells you when they start refusing you.

3. Inbound 404s. You can prove every internal link resolves. You cannot know that a newsletter sent four thousand people to a URL you renamed last quarter. That traffic hits your 404 page and evaporates.


Two beacons, roughly forty lines

The temptation is to solve this by installing an APM product and inheriting a new script tag, a new vendor, and a new consent banner. You don’t need one. You need two events reported and nothing else.

Client errors. Listen on both failure channels and send the result with sendBeacon, which survives the page being closed:

// src/scripts/report.js
const report = (payload) => {
  try {
    navigator.sendBeacon(
      '/api/report',
      new Blob([JSON.stringify(payload)], { type: 'application/json' })
    )
  } catch {
    /* reporting must never throw */
  }
}

window.addEventListener('error', (e) => {
  report({
    kind: 'error',
    message: e.message,
    source: `${e.filename}:${e.lineno}:${e.colno}`,
    page: location.pathname,
    ua: navigator.userAgent,
  })
})

window.addEventListener('unhandledrejection', (e) => {
  report({
    kind: 'rejection',
    message: String(e.reason?.message ?? e.reason),
    page: location.pathname,
  })
})

One detail that costs an afternoon if you don’t know it: for a script served from a different origin, the error event gives you the string Script error. and nothing else — no message, no line number. Getting the real values requires crossorigin="anonymous" on the <script> tag and an Access-Control-Allow-Origin header on the asset. If you self-host your JS, which on a static site you should, this never comes up. One more argument for not parking your bundle on someone else’s CDN.

Inbound 404s. Your 404 page is the only page on the site that knows something went wrong. Make it say so:

// only on the 404 route
report({
  kind: '404',
  path: location.pathname,
  referrer: document.referrer || null,
})

The referrer is the entire value here. A 404 with no referrer is usually a bot or a typo and you can ignore it. A 404 with a referrer is a real link on a real site pointing at a page you moved, and it is worth a redirect rule.

The receiving end can be one small serverless function that appends to a log or posts to a webhook. Keep it dumb. It has a single job, and if it falls over nothing user-facing breaks — which is the correct blast radius for monitoring code.

This is not analytics. It records failures, not people. No identifiers, no session stitching, no per-visitor history — which is also why it doesn’t drag a consent flow along behind it.


Synthetic checks that assert on content

Close the last gap with a scheduled fetch of the real, deployed site that makes a claim about what came back:

name: smoke
on:
  schedule:
    - cron: '17 */6 * * *'
  workflow_dispatch:

jobs:
  smoke:
    runs-on: ubuntu-latest
    steps:
      - name: Homepage renders real content
        run: |
          curl -fsSL https://example.com/ | grep -q '<article' \
            || { echo "no posts rendered"; exit 1; }

      - name: Feed is not empty
        run: |
          curl -fsSL https://example.com/rss.xml | grep -q '<item>' \
            || { echo "empty feed"; exit 1; }

      - name: Missing pages really 404
        run: |
          code=$(curl -s -o /dev/null -w '%{http_code}' https://example.com/definitely-not-a-page)
          [ "$code" = "404" ] || { echo "expected 404, got $code"; exit 1; }

That last step is the one people skip and the one that catches catastrophes. A host misconfiguration that starts serving your 404 page with a 200, or your index page for every unknown path, is invisible to every other test you own — and it will quietly hand a search engine thousands of duplicate pages while your dashboards stay green.


The honest tradeoff

Static hosting genuinely removed entire categories of failure. There is no runtime to exhaust, no connection pool to saturate, no database that can take your homepage down at midnight. That is real, and it is why we build this way.

But “fewer failures” quietly became “fewer signals,” and those are not the same win. The failures you have left are slower, quieter, and biased toward the parts of the site that make money — forms, checkout links, anything touching a third party. A server would have told you. The edge will not.

Budget a few hours to put the signal back: assertions on dist/ in CI, two beacons in the browser, one scheduled fetch that greps for a string. That is an afternoon’s work and no new vendor.

The alternative isn’t a site with no errors. It’s a site where you’re the last to find out about them.