Building My Blog With Lessons From Production Engineering
This site started as a fairly ordinary personal blog.
The content lives mostly as Markdown, the frontend is a small Vite application, and the whole thing is served through Cloudflare. Over time, though, it accumulated more responsibilities than the word "blog" suggests.
Today, the public site can read from two content sources. Some posts are bundled with the application as Markdown, while others can be published through an admin interface and stored in Cloudflare D1. A Cloudflare Worker sits in front of the site and acts as both the web server and the application backend. It serves the built frontend, exposes the public and admin APIs, handles things like comments and reactions, and also performs a small amount of server-side rendering for public pages.
The frontend is still intentionally lightweight. There is no React or Vue layer in the middle. It is mostly browser APIs, JavaScript modules, Vite, and a collection of fairly small feature modules.
A rough version of the architecture looks like this:
flowchart TD
A[Browser] --> B[Cloudflare Worker]
B --> C[Static frontend assets]
B --> D[Public APIs]
B --> E[Admin APIs]
B --> F[Server-rendered public HTML]
D --> G[Cloudflare D1]
E --> G
E --> H[Cloudflare R2]The frontend itself has two ways to obtain post content. Bundled Markdown provides a static source of truth that can ship with the application, while published D1 posts allow content to be managed through the admin interface. D1 takes precedence when a post exists there, while the bundled Markdown remains a useful fallback and keeps the site capable of working in a simpler static deployment.
None of this was particularly complicated when each feature was added individually. The problem was that the total system kept growing.
The site eventually had an admin editor, drafts, revision history, syntax highlighting, Mermaid diagrams, comments, reactions, analytics, recommendations, RSS, sitemaps, a service worker, uploads, and server rendering. At that point, the interesting question was no longer whether the application had too many features.
It was how much bandwidth a reader should have to spend to use them.
That question is also something I have become much more sensitive to through my work as a software engineer. A lot of performance problems do not come from one catastrophically slow component. They come from systems gradually doing more work than the caller actually asked for: returning more data than necessary, initializing dependencies too early, repeating work across layers, or making one user's optional path part of everybody's critical path.
Over time, I have learned to look for those boundaries first. Before making something faster, I try to ask whether it needed to happen at all, whether it needed to happen now, and whether it needed to happen for this particular request.
The blog became a good place to apply that way of thinking end to end.
I wanted the site to keep becoming more capable without making every page load proportionally heavier. Someone opening the homepage should not receive the admin editor. A post without diagrams should not pay for Mermaid. The feed should not contain the Markdown body of every article. Adding a long post should not increase the payload for every visitor who never opens it.
The goal was not simply to make the blog "fast." It was to make the amount of data crossing the network as closely as possible match what the reader had actually asked for.
A post is not one thing
The first place this became obvious was content loading.
A blog post is convenient to think of as one object containing a title, date, tags, summary, and body. But those fields are not equally useful everywhere.
The homepage needs enough information to display a feed and let me filter or sort it. It needs the slug, title, publication date, summary, tags, category, and reading time. It does not need the full Markdown body of every article.
That distinction matters more as the archive grows. If fifty posts are bundled into the application and each one contains several kilobytes of Markdown, the browser can end up downloading a significant part of the site's entire history before the reader has chosen a single article to open.
So the current build separates post metadata from post content.
At build time, a small Vite plugin walks the files under src/posts, parses their front matter, and creates a metadata-only manifest. The parser is explicitly told not to include the body:
parsePostFile(modulePath, raw, {
includeBody: false,
});The generated manifest contains things like the slug, title, date, summary, tags, category, reading time, and source path, but not the Markdown itself.
That is enough to build the feed, populate the tag list, search posts, sort them, and generate navigation.
The body stays elsewhere.
Letting Vite load a post only when it is read
The Markdown files themselves are registered with Vite using import.meta.glob():
const postModules = import.meta.glob(
["../posts/*.md", "../posts/**/*.md"],
{
query: "?raw",
import: "default",
}
);The important detail is that the glob is not eager.
Vite therefore keeps each post behind a loader rather than immediately pulling every Markdown file into the startup graph. When a reader opens a particular article, the application finds that post's path in the metadata manifest and invokes the corresponding loader.
The loading path is roughly:
flowchart TD
A[Open homepage] --> B[Load post metadata]
B --> C[Render feed]
C --> D{Reader opens a post?}
D -- No --> E[No post body downloaded]
D -- Yes --> F[Resolve post path]
F --> G[Load that post's Markdown]
G --> H[Render article]This means the cost of adding a long article mostly belongs to the readers who choose to open that article.
It does not automatically become a cost paid by everyone who visits the homepage.
I found this to be a much more useful way to think about frontend performance than simply chasing smaller bundle numbers. The question becomes less about making all content cheap and more about making sure the right person pays for the right content.
The D1 API follows the same model
The same distinction had to exist on the server side.
Published posts live in D1, and the public API has both a list endpoint and a post-detail endpoint. It would have been easy to make the list endpoint return the complete database rows, including body_markdown, simply because the body is already there.
That would have undone the frontend optimization in a different layer.
So the post list endpoint intentionally selects only the fields needed by the feed:
SELECT
slug,
title,
summary,
description,
tags_json,
category,
published_at,
updated_at,
og_image,
canonical,
reading_time
FROM blog_postsThe body is only selected for requests that actually need the article.
I also added a regression test for that behavior:
expect(data.items[0]).not.toHaveProperty("body");This test is less about object shape and more about preserving an architectural promise. The feed API should remain a feed API. A future refactor should not quietly turn it into a full-content export.
The list endpoint is cursor-paginated as well. The browser receives a page of metadata and a cursor for the next page instead of receiving the entire archive in one response.
Again, the idea is the same: return enough to continue, not everything that might eventually become useful.
One repository, two content sources
From the browser's point of view, posts come through a single repository layer.
That repository knows how to load from the D1-backed API and how to fall back to the bundled Markdown version of a post when necessary. The public frontend does not need to care much about which source ultimately produced the content.
D1 takes precedence for published content, which allows posts created or edited through the admin UI to replace the bundled version without changing the public routing model.
The bundled copy remains useful as a fallback and also allows the site to continue functioning in a reduced static-only deployment.
The resolution path looks like this:
flowchart TD
A[Request post by slug] --> B[Try D1 API]
B --> C{Post found?}
C -- Yes --> D[Use D1 post]
C -- No --> E[Load bundled Markdown lazily]
D --> F[Return post]
E --> FThe important part is that the fallback remains lazy. Keeping a second content source does not mean downloading it all pre-emptively.
Loading Markdown is not the same as rendering Markdown
Once a post body has actually been requested, there is still another dependency boundary.
Rendering Markdown on the client requires marked and DOMPurify. Neither is useful on the homepage.
So the renderer is loaded only when content actually needs to be rendered:
let markdownModulePromise;
export async function renderMarkdownOnDemand(markdown) {
markdownModulePromise ||= import("./markdown");
const { renderMarkdown } = await markdownModulePromise;
return renderMarkdown(markdown);
}The first render loads the Markdown implementation. Later renders reuse the same promise.
This is one of the reasons I like dynamic imports so much. They are not only a bundling trick; they let the code itself express a timing decision.
A normal import says that a dependency belongs to the current graph.
A dynamic import says that this work belongs later.
That distinction appears several times throughout the site.
Mermaid belongs to diagrams, not to the homepage
Mermaid is a good example of a feature that can easily become a global dependency by accident.
A few technical posts contain diagrams. Most do not.
Before importing Mermaid, the article enhancer first checks whether the current document actually contains a Mermaid block. If it does not, the function returns immediately. Only a page with a diagram causes the Mermaid package to be loaded.
Syntax highlighting works the same way. The browser checks whether the rendered article contains code that actually needs highlighting before loading the syntax-highlighting implementation.
The article-rendering path therefore becomes conditional:
flowchart TD
A[Article body loaded] --> B[Load Markdown renderer]
B --> C[Render HTML]
C --> D{Highlightable code?}
D -- Yes --> E[Load syntax highlighter]
D -- No --> F[Skip]
C --> G{Mermaid diagram?}
G -- Yes --> H[Load Mermaid]
G -- No --> I[Skip]I have come to think of this as feature ownership.
Mermaid belongs to Mermaid diagrams. The syntax highlighter belongs to code blocks. The Markdown parser belongs to Markdown rendering. None of them inherently belong to the public application bootstrap.
That distinction sounds small, but it is one of the easiest ways to stop a frontend from gradually turning every optional capability into mandatory startup work.
The admin interface is deliberately a separate branch
The admin side of the blog has grown into something much closer to a second application.
It contains GitHub authentication, post management, an editor, draft handling, revision history, uploads, analytics, publishing controls, and its own CSS.
A reader opening an article needs none of that.
The entire admin runtime therefore starts behind a dynamic import:
import("../features/admin/runtime")The modules reachable only through that runtime remain outside the initial public dependency graph. The admin stylesheet is imported from inside that lazy branch too, which lets Vite separate the CSS.
The application is effectively split like this:
flowchart TD
A[Application bootstrap] --> B[Public runtime]
A --> C{Admin needed?}
B --> D[Feed / posts / navigation]
C -- No --> E[Admin bundle stays unloaded]
C -- Yes --> F[Load admin runtime]
F --> G[Editor]
F --> H[Admin API]
F --> I[Draft state]
F --> J[Admin CSS]This makes the public and admin sides share a codebase without forcing them to share the same startup cost.
There is one intentional exception. If the browser already appears to have an authenticated admin session, the application can preload the restricted runtime and revalidate that session. In that situation there is a reasonable chance the admin functionality will be needed shortly, so paying for it early is useful.
That is an important distinction. Optimization is not about delaying everything. It is about having enough context to know what deserves to happen now.
Code splitting is tested, not assumed
One problem with lazy loading is that it can regress silently.
A shared helper starts importing an admin module. A stylesheet moves to a common file. A dependency that used to live behind a dynamic import becomes reachable from the public graph.
Everything still works.
The site just gets heavier.
To catch that, the production build inspects its own generated output. It checks for representative strings that should only exist in the admin JavaScript, as well as selectors that should only exist in admin CSS. If those markers appear in the initial public assets, the build fails.
That gives me a much stronger guarantee than simply looking at the source and assuming the split still behaves the way I intended.
The generated artifacts are part of the contract.
Bundle size has a hard ceiling
The build also enforces explicit performance budgets for the initial document, JavaScript, and CSS.
The current limits are:
| Asset | Raw limit | Gzip limit | Brotli limit |
|---|---|---|---|
| HTML | 8 KiB | — | — |
| Initial JavaScript | 200 KiB | 35 KiB | 30 KiB |
| Initial CSS | 55 KiB | 12 KiB | 10 KiB |
The script compresses the production output while checking these values, so the compressed budgets are based on the actual generated assets rather than an estimate.
If the limits are crossed, the build fails.
I like this because most frontend bloat does not arrive as one obviously bad commit. It accumulates through a series of perfectly reasonable local choices. A budget turns that slow drift into something visible.
Performance becomes part of the definition of a successful build.
The Worker does just enough server rendering
The Cloudflare Worker is not only an API layer. It also helps with the initial HTML response.
For public routes, it can resolve the requested post from D1 or from the generated bundled-content manifest, then inject route-specific metadata such as the canonical URL, Open Graph fields, Twitter metadata, and JSON-LD.
For supported pages it can also render the initial page markup and embed initial application state into the response.
The browser knows how to reuse that markup.
The request path looks roughly like this:
sequenceDiagram
participant B as Browser
participant W as Cloudflare Worker
participant D as D1 / Static Content
B->>W: GET /post/some-post
W->>D: Resolve post + metadata
D-->>W: Post data
W->>W: Render initial HTML
W-->>B: HTML + metadata + initial state
B->>B: Reuse SSR DOM
B->>B: Attach client-side behaviorThis avoids a wasteful pattern where the server renders useful content, the browser receives it, the SPA boots, removes it, requests the same data again, and recreates the same page.
The Worker does enough server-side work to make direct page loads useful for readers, search engines, and social previews. The browser then takes over navigation and interactivity.
I get most of the benefits I care about from SSR without introducing a separate frontend framework or maintaining two completely different rendering systems.
The build output follows the same content model
The generated static assets follow the same separation between metadata and bodies.
The build writes a content-manifest.json containing information about available posts, while each bundled article body is emitted as its own content asset.
flowchart TD
A[Build] --> B[content-manifest.json]
A --> C[Post body assets]
B --> D[Metadata for post A]
B --> E[Metadata for post B]
B --> F[Metadata for post C]
C --> G[post-a.json]
C --> H[post-b.json]
C --> I[post-c.json]That means the Worker can answer questions about routes and metadata without opening every article body.
I did not initially set out to use the same model in so many places, but it has ended up being one of the cleaner aspects of the architecture.
The browser bundle, the D1 API, and the static build all agree on the same basic idea: metadata and content are different resources with different costs.
HTTP gets a turn too
After reducing the amount of data being requested, the next question was whether unchanged data needed to be sent again at all.
Cacheable JSON responses receive ETags derived from their contents. A later request can send If-None-Match, and the Worker can return 304 Not Modified when the response is unchanged.
The interaction is simple:
sequenceDiagram
participant B as Browser
participant W as Worker
B->>W: GET /api/posts
W-->>B: 200 + JSON + ETag
Note over B: Later...
B->>W: GET /api/posts + If-None-Match
W-->>B: 304 Not ModifiedThe service worker is deliberately conservative for similar reasons. Hashed static assets are safe to cache aggressively because changing the file changes its URL. HTML and other unversioned resources stay network-first, especially because the Worker can produce route-specific server-rendered content.
I am much more comfortable caching things when their identity makes invalidation obvious. "Cache everything" tends to create very fast stale data.
There are also a few smaller optimizations at the document level. The saved theme is applied in the page head before the main application initializes, avoiding a flash from light to dark mode. A small amount of boot styling is inline so the first frame has a sensible background and shell before the full stylesheet loads.
These do not move huge numbers on a bandwidth chart, but they matter to how quickly the site feels settled.
What this avoids
The easiest way to understand the current architecture is to compare it with the version I deliberately did not build.
I have not included absolute byte counts here because those depend on the current set of posts and generated bundles, and I would rather not present invented benchmark numbers. The difference in what is transferred, however, is structural.
| Area | Without these optimizations | Current implementation |
|---|---|---|
| Homepage post content | Bodies for bundled posts can become part of the eager application graph | Metadata only; bodies are lazy-loaded per post |
| D1 feed endpoint | List response could include body_markdown for every returned post |
List endpoint returns metadata only |
| Large archives | One large list response | Cursor-paginated metadata |
| Markdown rendering | Parser and sanitizer included at startup | Loaded when Markdown actually needs client rendering |
| Syntax highlighting | Highlighter shipped globally | Loaded only when highlightable code exists |
| Mermaid | Mermaid shipped to every reader | Loaded only for posts containing diagrams |
| Admin interface | Editor, admin APIs, state and CSS included with public application | Entire admin runtime is deferred |
| Static content manifest | Could contain every article body | Metadata manifest plus separate body assets |
| Repeated API requests | Same JSON body can be transferred again | ETags allow 304 Not Modified |
| Initial direct post load | Client may need to fetch and rebuild the page | Worker provides SSR markup and initial state that the client can reuse |
| Bundle growth | Performance depends on developers noticing regressions | Raw, gzip and Brotli budgets fail the build |
| Code-split regressions | Admin code can accidentally leak into public bundles unnoticed | Build checks generated assets for admin leakage |
| Service-worker caching | Aggressive caching risks stale SSR/content | Hashed assets cache well; HTML stays network-first |
The important difference is not just that individual requests are smaller. The site avoids making requests and transfers that are unrelated to what the reader is currently doing.
Keeping bandwidth proportional to intent
This was ultimately what I wanted from the architecture.
The site can have a fairly complete feature set without behaving as though every visitor intends to use every feature in the same session.
A homepage visitor needs the shell and enough metadata to browse. Someone opening an article needs one article body. A post with source code may additionally need the highlighter. A post with diagrams may need Mermaid. I need the admin editor when I am actually administering the site.
Those are different users, different pages, and different moments. Treating them as one giant startup requirement would be convenient for the application, but wasteful for the network.
A simplified version of the current bandwidth path looks like this:
flowchart LR
A[Visit site] --> B[Initial app]
B --> C[Post metadata]
C --> D{Open article?}
D -- No --> E[Stop]
D -- Yes --> F[One post body]
F --> G[Markdown renderer]
G --> H{Code?}
H -- Yes --> I[Syntax highlighter]
G --> J{Diagram?}
J -- Yes --> K[Mermaid]
B --> L{Admin session / route?}
L -- Yes --> M[Admin runtime]The useful thing about this diagram is that most branches are optional.
That is the point.
Where I ended up
The optimization goal for this blog was never to strip features until the site became tiny. I wanted almost the opposite: I wanted room to keep adding features without making every reader carry the combined weight of all of them.
That instinct comes directly from experience.
Working on larger systems over the course of my career has made me increasingly wary of accidental coupling between capability and cost. APIs that return more than their callers need, background work that quietly becomes part of a request path, expensive dependencies that migrate into shared initialization, or systems that repeatedly compute something because the boundaries between components are not quite right are all versions of the same problem.
The scale of a personal blog is obviously very different from a production platform, but the engineering habit transfers surprisingly well. Small systems are also a useful place to practice it because the entire stack is visible at once. I can trace a byte from a D1 row, through a Worker response, into a browser module, and all the way to the thing that eventually appears on screen.
That makes the blog a convenient testbed for ideas I have learned elsewhere: keep interfaces narrow, defer work until there is demand for it, make expensive paths explicit, avoid duplicating work across layers, and put automated checks around properties that would otherwise be easy to regress.
The goal here was bandwidth. I wanted the site to offer a full set of features while transferring as little unnecessary data as possible.
A site with fewer features can obviously send fewer bytes. That is not particularly interesting. The harder problem is keeping server rendering, diagrams, syntax highlighting, an admin editor, D1 publishing, comments, reactions, analytics, recommendations, and offline-aware behavior while ensuring that an ordinary reader does not have to download the combined cost of all of them.
Most of the work therefore went into avoiding unnecessary transfer rather than trying to make every possible transfer slightly smaller.
The browser should receive post metadata before post bodies because that is what the feed needs. It should receive one body when one body is requested. It should receive Mermaid when a Mermaid diagram exists. It should receive the admin application when the admin application is relevant. If a response has not changed, ideally it should receive no response body at all.
That is the property I want to preserve as the site grows:
bandwidth should follow intent.
The overall application is allowed to become more capable. An individual page load should not automatically inherit the cost of that growth.
Adding ten more posts should not mean every visitor downloads ten more posts. Adding another admin feature should not increase the public startup bundle. Adding an expensive article capability should only affect articles that use it.
The specific tools will change eventually. D1 may not always be the database. Vite may evolve. The Worker implementation will almost certainly continue changing.
But one lesson from working on software professionally has become fairly durable for me: before spending time making an operation faster, first ask whether that operation, request, dependency, or byte needed to be there in the first place.
For this blog, that question ended up being the most effective optimization of all.