Skip to content

perf: edge-cacheable HTML, lighter first visit, no GitHub wait on /download - #48

Merged
datlechin merged 7 commits into
mainfrom
perf/edge-cache-weight
Oct 5, 2026
Merged

datlechin merged 7 commits into
mainfrom
perf/edge-cache-weight

Conversation

@datlechin

Copy link
Copy Markdown
Member

Why

Every page view crossed from Cloudflare to the origin in Singapore and ran the SSR process. Measured from Vietnam:

  • HTML pages take 250–450 ms to the first byte, against about 170 ms for a file Cloudflare already caches.
  • 79% of orders come from outside Asia, so most buyers are further away than that.

The first visit also carried a 156 KB PNG favicon and Google's 177 KB tag, and once every 15 minutes a reader of /download waited up to 10 s on GitHub.

Change

  • Edge-cacheable HTML (CacheHtmlAtEdge).
    • What it covers: GET/HEAD HTML with status 200, 404 or 410, that is not an Inertia visit and sets no cookie.
    • Header: public, max-age=0, s-maxage=600, stale-while-revalidate=3600. Vary: X-Inertia is kept.
    • What it leaves alone: Inertia JSON, redirects and 5xx keep their current headers.
    • Error pages: RenderErrorPage applies the same rule to 404s that match no route.
    • Why it's safe: every page depends on its URL alone. There is no session, cookie, CSRF or geo pricing, and the banner's dismissal lives in localStorage.
    • Nothing is cached until the Cache Rule below exists.
  • Deploy.
    • Purge: deploy.yml purges Cloudflare (purge_everything) from the runner after a successful deploy. Without the two secrets it prints a notice and passes. The token never reaches the server.
    • Old assets stay servable: deploy.sh carries the outgoing build's hashed assets into the new build and prunes them 72 h after they retire, so cached HTML or a failed purge never points at deleted JS/CSS.
    • Smoke test: it now adds ?deploy-smoke=<time>, so it never reads the edge cache.
  • Icons. logo.png drops from 156 KB to 13 KB and looks identical. A 180×180 apple-touch-icon.png (9 KB) is added.
  • Google Analytics. gtag/js is added after the load event once the browser is idle, as the chat loader already is. The consent stub and call order are unchanged. A reader who leaves before then is not counted.
  • Page chunk preload. The page component is a @vite entry, so its chunk is preloaded with the document, guarded by is_file.
  • /download. release:refresh runs every 15 minutes. Requests only read the stored copy, fall back to the last good one, or show "unavailable". A missing copy schedules one deferred refresh after the response, under the existing lock.
  • SSR. It listens on 127.0.0.1 (INERTIA_SSR_HOST overrides it) instead of every interface. Production already calls it at http://127.0.0.1:13715.

Tests

  • Full suite with SSR (REQUIRE_SSR=1): 1498 passed, 1 skipped. Without SSR: 1341 passed.
  • npm run test:js: 181/181. Typecheck, Pint and build pass.
  • New: EdgeCacheTest, PageChunkTest, IconsTest, AnalyticsConsentTest, SsrServerTest. DeployScriptTest gains cases for retention, the smoke URL and the purge step. MacReleaseServiceTest and DownloadPageTest are updated.

After merge

  1. Cron for the scheduler: /etc/cron.d/tablepro-web (done on the server with the deploy). It also starts the daily sitemap:generate.
  2. Secrets (owner): CLOUDFLARE_ZONE_ID, and CLOUDFLARE_CACHE_PURGE_TOKEN limited to Zone → Cache Purge on tablepro.app.
  3. Cache Rules (owner, only after this is deployed): listed in docs/deployment.md, "Caching". The bypass rule on the x-inertia header comes first, then "eligible for cache" for the rest of tablepro.app minus the platform paths.

🤖 Generated with Claude Code

Every page is a function of its URL alone (locale in the path, no session,
no cookie), yet each view crossed to the Singapore origin and the SSR
process because Laravel's default `no-cache, private` stood.

CacheHtmlAtEdge, first in the web group, marks GET/HEAD HTML responses
with status 200, 404 or 410 that are not Inertia visits and set no cookie
with `public, max-age=0, s-maxage=600, stale-while-revalidate=3600` and
keeps `Vary: X-Inertia`. RenderErrorPage applies the same rule, because a
URL that matches no route never reaches the web group. Inertia JSON,
redirects and 5xx keep their headers.

Nothing changes at the edge until a Cache Rule is added; docs/deployment.md
spells out the two rules (bypass X-Inertia, cache the rest of tablepro.app
outside the platform paths).

Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
…ssets servable

With pages cached at the edge, three things change about a deploy:

- The workflow purges the zone from the runner once deploy.sh succeeded,
  when CLOUDFLARE_ZONE_ID and CLOUDFLARE_CACHE_PURGE_TOKEN exist; without
  them it prints a notice and passes. The token never reaches the server.
- deploy.sh copies the outgoing release's hashed assets into the new build
  before the swap, so a page cached before the purge, a stale copy served
  while revalidating, or an open tab never points at deleted JS or CSS.
  Carried files are dropped ASSET_RETENTION_HOURS (default 72) after they
  left a live manifest.
- The smoke test still goes through Cloudflare, but with a query string no
  reader sends, so it never reads a page cached before the release it
  checks (a cached page would fail the build check and roll back a good
  deploy).

Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
…of the 156 KB PNG

/logo.png was a 256px PNG at 16 bits per channel, 156 KB, linked as the tab
icon, the apple-touch-icon and the manifest icon, so every first visit
downloaded it. It is now an 8-bit palette PNG at the same size with the
same Display P3 profile (12,985 bytes, PSNR about 50 dB against the
original), and iOS gets a 180px apple-touch-icon.png (9,203 bytes) at the
path it also probes on its own.

The URL /logo.png is unchanged for the structured data, the error pages and
the OG card templates that embed it.

Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
…ser is idle

gtag/js (about 180 KB) was requested from the document head on every page
and was the heaviest request in Lighthouse runs. The head now keeps the
inline stub, the consent defaults, the stored answer and `config` in their
contract order, and adds the script only after the load event and an idle
moment (two seconds after the load where there is no idle callback), the
way lib/crisp.ts loads the chat. `gtag()` queues every call in dataLayer
until then, consent.ts's included, and the tag replays them in order.

What is measured and the consent model are unchanged; a reader who leaves
before the page is idle is no longer counted.

Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
Pages are lazy chunks, so the browser learned of a page's code, and of the
fifteen-odd shared chunks it imports, only after app.tsx had run and asked
Inertia for the component: one more round trip before hydration on every
first visit. The root template now names the page's own file as a @Vite
entry, which sends its modulepreload and those of its imports with the
document. A component with no file is skipped rather than failing the
manifest lookup (none exists: SeoSmokeTest renders every registry page).

Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
…ing a page

When the cached release expired, the next /download request called GitHub
and then the appcast with 5-second timeouts, so one reader every 15 minutes
could wait up to ten seconds, and an edge-cached page would carry that wait
to the origin.

release:refresh, scheduled every 15 minutes, now does the fetching and
stores the release for 30 minutes (twice the schedule) plus the last good
copy on disk. MacReleaseService::latest() only reads: the stored copy, else
the last good copy, else `unavailable`. A missing copy (after cache:clear,
or with the scheduler stopped) asks for one refresh after the response has
been sent (defer), under the existing lock and failure marker, so the page
stays current without ever waiting on GitHub. Still at most four GitHub
calls an hour, healthy or not.

The server needs a cron entry for schedule:run (docs/deployment.md, "The
scheduler"); the daily sitemap job also starts running with it.

Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
Inertia's SSR server binds 0.0.0.0 unless told otherwise, and answers
/render and /shutdown to anyone who reaches port 13715, so on a host
without a firewall in front of it anyone could stop it and every page
would ship without server-rendered HTML. Only PHP on the same host talks
to it, at INERTIA_SSR_URL (http://127.0.0.1:13715), so the entry now passes
{ port, host } with host 127.0.0.1, overridable by INERTIA_SSR_HOST.
@inertiajs/react 3.8.0's createServer accepts the option.

Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
@datlechin
datlechin merged commit c3dd2b8 into main Oct 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant