← Back to blog
·7 min read·3 views

The One-Line Change That Became a Refactor

rustpreseussqlitecachingportfolio

I changed jobs in May. I mentioned it in the last post but never got around to updating the site itself, so my portfolio was still telling everyone I work somewhere I left two months ago.

The fix was supposed to take two minutes. Open site-en.toml, open site-pl.toml, edit the experience block, done.

And it did take two minutes. But then I had to rebuild and redeploy the entire application to make a date on a page change from “Present” to “May 2026”, and that’s the part that annoyed me enough to open the editor properly.

Because I already have an admin panel. I built it for blog posts months ago. Posts live in SQLite, I edit them in a browser, they show up. Meanwhile the thing that changes most often about a portfolio - what I’m actually doing for a living - required a compile.

So: move it to the database.

The Wrinkle I Didn’t See Coming

I assumed this was mechanical work. Add a table, write some CRUD, delete two TOML files.

Then I looked at how the homepage actually gets its data, and found build_state_fn. In Perseus that means the state is generated at build time - the page is baked into a static file and served as-is. Which is great, and is exactly why the site is fast.

It’s also completely incompatible with what I was trying to do. The database pool gets created when the server starts. At build time there is no pool, no database, nothing to query. A build-time state function cannot read from the database, no matter how I structure it.

So the homepage had to switch from build_state_fn to request_state_fn - from “serve a prebuilt file” to “render this on every request.” My most-visited page, going from static to dynamic, so that I could avoid a redeploy every few months.

That’s a genuinely bad trade if you stop there.

The Cache Isn’t For What I Thought

My original plan had a cache in it, and my reasoning was “don’t hit the database on every page view.”

That reasoning was wrong. Reading five rows out of a local SQLite file is microseconds. It is nothing. It’s far cheaper than the HTML rendering that has to happen anyway. If querying were the only cost, the cache would be pure premature optimization and I should have skipped it.

The cache is worth building for a different reason: it’s what pays for the static-to-SSR switch. The question isn’t “is the query slow,” it’s “how much work does a request do now that used to be zero.” So the thing worth caching isn’t the rows - it’s the finished, mapped, ready-to-render structs. A cache hit should cost a lock read and a pointer clone, and nothing else.

Which is what I built. A locale-keyed map behind an RwLock, values handed out as Arc<T> so a hit doesn’t copy any data.

The other decision that mattered: explicit invalidation instead of a TTL. Every write to this content goes through my admin panel, so I can clear the cache at the exact moment something changes. No staleness window, no “wait five minutes for your own edit to show up,” which is precisely the kind of small annoyance that would have made me stop using the admin panel and go back to editing files.

One subtlety I nearly got wrong: a failed load must not be cached. If the database hiccups for one request and I cache the empty result, my homepage stays blank until the next time I edit something. Only successes go in the cache. Failures are logged and retried on the next request.

The Slow Thing Was Somewhere Else

Here’s the part I didn’t expect.

While wiring the cache into the homepage, I looked at the blog index - which already used request_state_fn, so it was already doing per-request work. And on every single visit, for every published post, it was:

  1. Fetching the full row including the entire markdown body
  2. Running the complete markdown-to-HTML render
  3. Throwing all of it away to display a title, a date, and an excerpt

Every visitor. Every time. Re-parsing the full text of every post I’ve ever written, to render cards that show three lines each.

That was already happening, and had nothing to do with the change I set out to make. The experience table I was so carefully optimizing has five rows in it. The real waste was the page I wasn’t thinking about.

So the cache went there too, which is where it actually earns its place. This is the most ordinary lesson in performance work and I still walked straight into it: the thing you decide to optimize and the thing that’s actually slow are usually two different things, and you only find the second one by looking.

Verifying Without a Compiler

Awkward practical problem: I was working on a machine with no Rust toolchain installed. Writing a multi-file refactor across ten files and shipping it unverified was not an option.

Docker solved it - the project already builds in a rust:1.91-slim image, so I ran cargo check in a container with the dependency cache on a named volume. First check came back clean.

Suspiciously clean. It finished in under a second, which for a crate full of Perseus and Sycamore proc macros is not plausible. A green check that fast is more likely to mean “nothing was actually compiled” than “everything is correct.”

So I planted a deliberate type error in one of my new files and ran it again. It got caught, with the error pointing at the right line in the right module. That told me the check was real, the new modules were genuinely in the compilation graph, and the clean result meant something.

Verify your verification. A passing test you haven’t confirmed can fail is not evidence.

I did the same for the SQL, because “it compiles” says nothing about whether the migration runs. Two things I specifically didn’t trust:

  • The migration runner splits statements on ; - naive, and one semicolon inside a seed string would silently break it. So I simulated the split fragment by fragment against a scratch database. Eight fragments, all valid.
  • Reordering has edge cases - moving the top item up, the bottom item down, and two rows that share the same sort order. I tested all three. The tie case would have quietly done nothing without a fix.

None of this is clever. It’s just refusing to assume, in the specific places where assuming is cheap and being wrong is expensive.

What I Deliberately Left Alone

The individual blog post page also renders markdown on every request, and my first instinct was to cache it too.

I didn’t, because it’s already using Perseus incremental generation with a five-second revalidation window, and it increments a view counter per regeneration. Adding my cache underneath that would mean two caching layers with different lifetimes fighting each other, and a view count that gets even less accurate than it already is. Two mechanisms doing the same job badly is worse than one doing it adequately.

The other thing I’m accepting rather than solving: the cache is per-process. If I ever run more than one instance against the same database, an edit on one won’t invalidate the other. That’s a real limitation. It’s also irrelevant to a single-container portfolio site, and building for the multi-instance case now would be solving a problem I don’t have.

Was It Worth It?

For a site that gets the traffic mine does, the caching is honestly overkill. I’ll say that plainly.

What was worth it is the part I actually set out to fix: I can now change my job, reorder my timeline, or fix a typo in my bio from a browser, and it’s live on the next page load. No rebuild, no redeploy, no git push to change a date.

And I got the blog index fix for free, which is the only change here with a measurable effect on anyone but me.

The whole thing started because updating one line of my CV required a compile. It ended with three new tables, a cache layer, a migration runner that tracks what it has applied, and a decent reminder that the slow part is rarely where you’re looking.

Next time I change jobs, though, it really will take two minutes.

K

Kacper

Software Developer