Static generation with Hono was only the starting point

This site already used Hono JSX to compose pages and turn them into HTML at build time. It is not a client-side SPA that ships a large JavaScript bundle and waits for it to construct the page. The first response already contains the article, metadata, hreflang, and JSON-LD, so the original architecture was fast.

Generating static HTML, however, does not guarantee that the delivery path is as short as it can be.

  • Do static files still pass through a Hono Worker?
  • Does one Worker inspect hostnames for several sites?
  • Do HTML and content-hashed CSS receive the same cache policy?
  • Does an API response wait for email delivery?
  • Are related D1 writes executed one at a time?

The latest architecture changes those boundaries. Hono is no longer the entrance for every request. It is a thin control plane for the paths that are genuinely dynamic.

The resulting architecture

build-site.ts
  -> public (all generated pages and assets)
  -> prepare-site-deployments.ts
       -> dist/sites/main            -> Assets + Hono Worker
       -> dist/sites/nihongo-pocket  -> Assets only
       -> dist/sites/kage            -> Assets only
       -> dist/sites/madobeya        -> Assets only
       -> dist/sites/sokudoku        -> Assets only

main Worker
  /, /api/*, /healthz, /assets/ffmpeg-core/* -> Hono first
  other HTML, CSS, JS, and images             -> Assets directly

POST /api/contact
  -> Rate Limiting -> Turnstile -> D1 -> Queue -> Email
                                           -> 5 failures -> DLQ

POST /api/transcript-jobs
  -> Rate Limiting -> D1 hourly quota -> D1.batch -> Queue
                                                   -> 5 failures -> DLQ

Static generation and dynamic APIs remain in one repository, but they no longer share the same runtime path.

1. Do not start the Worker for ordinary pages

The main deployment lists only the paths that must run Hono first.

{
  "assets": {
    "directory": "./dist/sites/main",
    "binding": "ASSETS",
    "html_handling": "drop-trailing-slash",
    "run_worker_first": [
      "/",
      "/api/*",
      "/healthz",
      "/assets/ffmpeg-core/*"
    ]
  }
}

Assets finds and serves /ja, /en, blog pages, stylesheets, and images before Hono is involved. The dynamic path is limited to locale selection at /, APIs, health checks, and a special runtime fetched through R2.

Hono is lightweight, but the best isolate invocation for a static file is still no invocation. This shortens the execution path, reduces CPU use, and narrows the runtime failure surface.

2. Give each subdomain an Assets-only deployment

One Worker can inspect a hostname and rewrite requests for several sites, but that creates a shared runtime and deployment boundary. Each product site now has its own Assets deployment and Custom Domain.

muscleindustry.work                 -> main Worker + Assets
nihongo-pocket.muscleindustry.work -> Assets only
kage.muscleindustry.work           -> Assets only
madobeya.muscleindustry.work       -> Assets only
sokudoku.muscleindustry.work       -> Assets only

There is still one build. Before deployment, a preparation script copies only the relevant HTML and shared assets into a directory for each host. Every wrangler.<site>.jsonc points to its own output and Custom Domain.

This is useful beyond raw performance:

  • HTML intended for another host cannot leak into the deployment
  • hostname routing and runtime rewrites disappear
  • one configuration error is less likely to affect every site
  • CSP can be minimized for each product
  • static sites do not receive D1, R2, or secret bindings

As the number of static properties grows, a shared build with isolated delivery boundaries is easier to reason about than one universal Worker.

3. Cache according to the file's identity

The site now uses these policies:

Resource Cache-Control
HTML public, max-age=0, s-maxage=86400, stale-while-revalidate=604800
Content-hashed CSS and JS public, max-age=31536000, immutable
Other /assets/* files public, max-age=0, must-revalidate
robots, sitemap, RSS, app-ads public, max-age=3600
APIs no-store

Browsers revalidate HTML, while Cloudflare can reuse it at the edge for 24 hours. A valid stale response can continue to be served for up to seven days during revalidation. Content-hashed stylesheets and scripts are immutable for a year because changing the content also changes the URL.

Unhashed images and other assets do not receive the same promise. They must revalidate.

A subtle _headers inheritance trap

Cloudflare merges every matching _headers rule. A stylesheet can match /*, /assets/*, and its exact /assets/site-HASH.css route. Duplicate Cache-Control fields may then be joined instead of replaced.

The more specific rule must detach the inherited field before assigning its own value.

/*
  Cache-Control: public, max-age=0, s-maxage=86400, stale-while-revalidate=604800

/assets/*
  ! Cache-Control
  Cache-Control: public, max-age=0, must-revalidate

/assets/site-7482c240a6.css
  ! Cache-Control
  Cache-Control: public, max-age=31536000, immutable

Hashed filenames change on each build, so prepare-site-deployments.ts scans the output and generates exact rules automatically. Production smoke tests also inspect the real response with curl -D -; validating only the source _headers file is not enough.

4. Separate contact acceptance from email delivery

Waiting for an external email operation inside the request makes a contact API slower and less reliable. The current flow is:

  1. Enforce the JSON body limit and validate the Zod schema
  2. Stop bursts with a Cloudflare Rate Limiting binding
  3. Validate the Turnstile action and hostname
  4. Store the submission in D1
  5. Enqueue a notification job
  6. Return 202 Accepted
  7. Let a Queue consumer send through the Email binding

D1 persistence is the acceptance boundary. A temporary email delay does not lose the submission or keep the visitor waiting.

Cloudflare Queues provide at-least-once delivery, so the Message-ID is derived from the submission ID and the consumer checks D1's email_status before sending. The consumer retries five times and then moves a failed message to a dedicated dead-letter queue. If enqueueing itself fails, the row remains retry_pending and a daily Cron recovers it.

This improves response time and delivery reliability at the same time.

5. Use different controls for bursts and persistent quotas

Rate Limiting bindings are well suited to rejecting a short burst close to the Worker. They are not the only mechanism used for a durable usage allowance.

Transcript creation has two layers:

  • Rate Limiting binding for repeated requests inside 60 seconds
  • D1 for a persistent per-user hourly job allowance

The former KV counter was removed from the Worker bindings. The platform binding handles cheap short-window protection, while D1 provides an auditable quota across locations and isolates.

6. Group related D1 writes with batch()

Creating a video, its asset, and its transcript job with separate inserts adds round trips and can leave orphaned rows after a partial failure. The related statements are now executed together.

await db.batch([
  insertVideoStatement,
  insertAssetStatement,
  insertTranscriptJobStatement,
]);

The same pattern is used for bulk segment updates. It reduces database round trips and makes related records easier to keep consistent.

7. Treat types, workerd tests, and observability as architecture

Fast routing is not useful if bindings drift between code and the Cloudflare Dashboard. wrangler.jsonc is the source of truth, and wrangler types generates CloudflareBindings instead of relying on a handwritten environment interface.

Testing has two layers:

  • Node Vitest for content, rendering, API contracts, and Queue consumers
  • Cloudflare Workers Vitest integration for fetch, Assets, and D1 inside workerd

The production Worker emits structured logs, retains request logs, samples 5% of traces, and returns an X-Request-ID. It deliberately avoids logging IP addresses, raw contact messages, and tokens.

Deployments use wrangler deploy --strict so unexpected Dashboard drift stops the release. CI runs the quality gate, D1 migrations, R2 runtime upload, Queue and DLQ setup, four static deployments, the main Worker, and smoke tests across all five origins.

Results

The migration was not evaluated by a Lighthouse score alone. We also checked which URLs invoke the Worker, actual production headers, host isolation, no-store API responses, Queue bindings, and remote D1 connectivity.

Across 24 Lighthouse runs, the minimum Performance score was 97, SEO remained 100, the highest LCP was 2.33 seconds, TBT was 0ms, and CLS was 0. The main gain is that these user-facing results were preserved while reducing both the Worker execution surface and the shared failure surface.

When this architecture fits

This is not a universal answer for every web application. A service that renders authenticated HTML per request or depends on real-time state will need different boundaries.

It works particularly well when:

  • most public content can be generated at build time
  • SEO and complete initial HTML matter
  • only contact, AI, or a few tools are dynamic
  • several landing pages or product sites share one repository
  • delivery, APIs, D1, R2, and Queues should stay on Cloudflare

Resolve everything static at build time and let Assets serve it directly. Give Hono a small, explicit dynamic boundary. The final performance step was not another micro-optimization; it was removing runtime paths that did not need to exist.

References