repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

6.6 KB · 91 lines · markdown Raw History
 1---
 2title: Rewriting my blog in Rust
 3slug: rewriting-my-blog-in-rust
 4date: 2026-05-06
 5publish_date: 2026-05-06
 6tags: rust, webdev, performance
 7description: I rewrote this blog from Flask to Rust over an afternoon. The result is a single 3.5 MB binary that uses 14x less memory and serves 10x more requests per second.
 8cover_image: rust-blog-rewrite.webp
 9---
10
11This blog started out as a Wagtail site, then a Django site, then a Flask app, and as of today it's a single 3.5 MB Rust binary that loads every post into memory at startup and serves the whole thing from a tokio runtime. I wrote about [the URL design mistake from the Flask port](/posts/cool-uris-dont-change-unless-an-ai-rewrites-your-blog/) last week, so this one is about what the Rust port actually changed.
12
13## The stack
14
15The Flask version was about 540 lines in a single `app.py`, with Gunicorn out front, Mistune for markdown, Jinja2 for templates, and WeasyPrint for PDF export. Posts were loaded from `content/posts/*.md` on each request, parsed, then rendered.
16
17The Rust version is roughly 1300 lines split across five files (`main.rs`, `posts.rs`, `markdown.rs`, `templates.rs`, `pdf.rs`). The pieces:
18
19- [axum](https://docs.rs/axum) for HTTP routing. Handlers are plain async functions, no macros.
20- [comrak](https://docs.rs/comrak) for markdown. It has a `create_formatter!` macro that lets you override the HTML for individual node types, which is how I kept the same `div.block-*` wrapping the Mistune custom renderer produced.
21- [minijinja](https://docs.rs/minijinja) for templates. It accepts Jinja2 syntax verbatim, so the `templates/` directory came over without rewrites. Two whitespace tweaks and a parens fix on a ternary, that was it.
22- A `chrome-headless-shell` subprocess for PDF export in place of WeasyPrint, which is the same idea with a different tool.
23
24Posts are read from disk and parsed once at startup, so after that every request is just a hashmap lookup and a template render with no filesystem traffic or markdown work involved.
25
26## Benchmarks
27
28I ran [oha](https://github.com/hatoo/oha) against both versions, ten-second bursts at fifty concurrent connections, with the production stack on each side (Gunicorn with four workers for Flask, release build for Rust). Both apps shared the same templates, the same content directory, and the same Vite-built static assets.
29
30Loopback inside the same container, just framework overhead:
31
32| Route | Flask RPS | Rust RPS | Speedup |
33|---|---:|---:|---:|
34| `/` | 4,485 | 31,634 | 7.1x |
35| `/blog/` | 2,119 | 22,059 | 10.4x |
36| `/posts/<slug>/` | 4,134 | 43,732 | 10.6x |
37| `/sitemap.xml` | 6,190 | 67,257 | 10.9x |
38| `/search/live/` | 9,026 | 116,416 | 12.9x |
39| `/og/<slug>.svg` | 8,144 | 170,492 | 20.9x |
40
41p99 latency on `/blog/` dropped from 30.3 ms to 7.5 ms. The OG SVG endpoint sees the biggest gain because it's pure template rendering with no markdown work, so framework overhead dominates and Python pays it on every byte.
42
43Container-to-container with Caddy in front was less dramatic but still real:
44
45| Route | Flask RPS | Rust RPS | p99 Flask | p99 Rust |
46|---|---:|---:|---:|---:|
47| `/` | 1,616 | 2,511 | 45.9 ms | 36.5 ms |
48| `/blog/` | 601 | 1,266 | 129.0 ms | 69.3 ms |
49| `/posts/<slug>/` | 1,162 | 2,569 | 71.0 ms | 34.9 ms |
50| `/sitemap.xml` | 1,966 | 5,178 | 870.5 ms | 17.8 ms |
51| `/search/live/` | 3,002 | 8,149 | 29.6 ms | 12.0 ms |
52| `/og/<slug>.svg` | 2,831 | 29,353 | 27.5 ms | 3.9 ms |
53
54The Flask sitemap p99 of 870 ms isn't a typo. Under load there's a long tail you'd actually feel in production, where the Rust version comes in at 17.8 ms on the same route.
55
56## Memory
57
58I wasn't expecting this part to be quite so lopsided.
59
60Idle, post-warmup RSS:
61
62- Flask, 4 Gunicorn workers: **347 MB**
63- Rust, single process: **24 MB**
64
65Under 50 concurrent sustained load:
66
67- Flask: **147 MiB**
68- Rust: **4 MiB**
69
70The Flask number isn't really about Python being wasteful, it's that Gunicorn forks four workers and each one carries its own copy of Flask, Jinja2, Mistune, WeasyPrint, and a few hundred lines of imported modules, which is 80-something MB per worker before the app does any work at all. Tokio runs everything on one heap with a work-stealing pool so there's nothing to multiply.
71
72On a $5 VPS that's the difference between the blog being something you have to budget for and something you can forget about.
73
74## What didn't get smaller
75
76The container image got bigger rather than smaller, going from 854 MB on Flask to 1.16 GB on Rust, and the whole 800 MB of that is `chromium-headless-shell` for PDF export. Without PDFs the runtime image would be about 180 MB on alpine, or under 25 MB if I went static-musl on scratch, but I want PDFs more than I want a small image so chromium stays.
77
78Iteration speed also got worse, since Flask reloads on save and Rust is `cargo build` every time, which is two to five seconds for an incremental debug build and around thirty seconds for a release build with LTO. `cargo watch -x run` makes it tolerable but you don't get the instant feedback loop you do in Python. I noticed it most while tweaking templates until I remembered that minijinja reloads templates from disk in debug mode without recompiling the binary, which covers most of what I was iterating on anyway.
79
80## Why a static-site generator wasn't the answer
81
82The obvious answer to a slow blog is to stop running a server and generate HTML at build time, and I did think about it. The reason I didn't is that a few endpoints here are dynamic on purpose, like PDF export, server-rendered search, OG image generation per post, and redirect handling for the old `/blog/<slug>/` URLs. A static site would need a sidecar for all of that and the sidecar needs a runtime of its own, so I'd end up with two deploy targets instead of one.
83
84The Rust binary handles all of it in one process, parses every post at boot in single-digit milliseconds, and idles at 24 MB, which is close enough to static for me.
85
86## Was it worth it
87
88The speedup is honestly more than I needed, since this blog gets nowhere near 1,000 RPS in real traffic. The things I ended up appreciating were the ones I wasn't looking for, like the memory drop, the long tail on the sitemap going away, and the binary starting in under fifty milliseconds and refusing to start at all if a post has malformed frontmatter. That last one caught two stale fields in old posts I'd forgotten about.
89
90If you've got a small Flask or Django app whose footprint annoys you more than its speed does then axum with minijinja and comrak is a surprisingly direct port. The Jinja2 templates carried over almost untouched, the custom Mistune renderer mapped one to one onto a comrak formatter, and the deploy didn't change at all.