It is the backend for a band coordination app: shipped iOS and Android clients talking to exactly one service. That backend was a Django BFF, a backend-for-frontend: one service shaped around its clients rather than generic resources. It spoke JSON REST to the mobile apps, a WebSocket envelope for live sync, and server-rendered pages for the web admin, and it had accreted every classic Django-era pattern: Celery with beat and Flower, Django Channels, gunicorn and daphne on separate deployments, and a mypy config bolted everywhere because a dynamically typed codebase needs a chaperone.
We rewrote it anyway. This post is the honest ledger: why, how we made rewriting a live product survivable, what it cost, what went well, and when not to bother.
Why rewrite a working backend?
The backend worked. The reasons to rewrite were structural, and our architecture notes list them plainly: performance, memory and deployment footprint, type safety, long-term maintenance, preference.
- Types were a patch, not a foundation. We treated mypy as non-negotiable across the Django codebase. That is an admission: we were simulating, in a linter, the guarantees Rust makes structurally. We wanted the compiler to hold the line, not a checklist.
- The process graph kept growing. HTTP on gunicorn, WebSockets on daphne, jobs on Celery plus beat plus Flower. The Rust replacement has the shape we always wanted: one axum binary serving HTTP and WebSockets, and a single worker crate. The repo is now a Cargo workspace:
apps/apifor the BFF,apps/workerfor background jobs, plus crates for domain logic, database access, integrations, and web templates. - We wanted the database visible. With SQLx, queries are SQL and rows come back typed against Postgres, not an ORM where the relationship graph is runtime behavior. More on the honest caveats of that in a moment.
- The hard dependency was already Rust. Availability sync runs on CRDTs, conflict-free replicated data types: structures where concurrent edits merge deterministically without coordination, the right tool for editing availability from phones in varying states of signal. Django reached that through pycrdt, which is the Python binding of the Rust
yrslibrary. Going native dropped a binding without changing the algorithm or the stored bytes.
That was the motivation. None of it changes the central problem: a live product does not care about your motivation.
The strategy: freeze the contract first
Every iOS and Android build in the field expects specific endpoints, JSON keys, status codes, a WebSocket SyncMessage envelope, and an HS256 JWT wire format. So we locked one rule before any code moved: the mobile API surface does not change in this rewrite. Not payloads, not status codes. What deserves to change ships after cutover, as a versioned contract change. The phrase we kept using was behavior-identical: a rewrite, not a redesign.
A freeze is only words until you can prove it. We wrote golden contract tests against the live Django app, one suite each for accounts, bands, events, songs, and availability, and those tests became the rewrite spec. The Rust route table carries the rule in its header comment: every response shape must reproduce Django byte-for-key.
The best illustration is web sessions. The web side authenticates with Django session cookies, and both stacks share one database, so Rust had to read and write Django’s exact format: a JSON payload, optional zlib compression, unpadded URL-safe base64, a base-62 timestamp, and an HMAC-SHA256 signature keyed by the SHA-256 of a salt, the literal string signer, and Django’s SECRET_KEY. Reimplementing that codec against the shared django_session table meant that during cutover, Rust and Django read each other’s sessions and nobody had to log in again. Unglamorous work, and exactly what made running two stacks side by side safe instead of theoretical. The same discipline applied everywhere: wire-compatible JWT claims, matching token hashing, even ETag formatting.
One more rule kept the data story sane: the schema stays Django-owned until cutover. The Rust repo’s migrations directory sits deliberately empty, and the code reads the schema Django’s migrations created. One data model owner, no exceptions.
What made it hard
We want to be candid here, because the hard parts are the useful part of the story.
- You relearn everything you had for free. Django is a battery: sessions, auth, admin, templating, email, static files, and an ORM that papers over SQL. In Rust each is a crate plus patterns you assemble. Nothing we chose was bad, but “it works like Django” was never automatic, and the first weeks were vocabulary.
- SQLx checks queries when you least want it to. The dream is compile-time checked SQL, and SQLx’s macro path does exactly that. In practice, most of our repositories use its runtime-checked functions, where the query is a plain string checked against the database the first time it runs. That trades compile errors for test coverage, so we lean hard on the contract tests and on a diff proxy, a soak script that replays identical requests against both services on the same database and diffs the responses. It earned its keep immediately: the two languages format datetimes differently by default, and our availability endpoints were subtly wrong until the diff caught it.
- No admin for free. Django admin is a genuinely great tool, and we gave it up on purpose: operational surfaces move to database tooling, and we build only the web we need. If your team lives in the admin all day, price that loss first.
- Two sources of truth for the whole runway. While the port progressed surface by surface, Django kept serving traffic. Every response shape was reproduced by hand, and drift was the standing risk; the cures are the golden tests, the diff proxy, and discipline, because the temptation to quietly “improve” a response while porting it is constant. Most of the project was this trivia: push list ordering, logo URL shapes, timestamps, webhook idempotency records. Each item is small, and the set of them is the project.
- The port had teeth where the data model was subtle. Two war stories from
yrs. First, in the version we pinned, asking for the doc’s root array takes its own internal transaction, so calling it while already holding a transaction deadlocks; we caught that in a probe, not production. Second, persistence: exporting a whole-document update and exporting one transaction’s update are different things, and an early persistence path used the latter, which covers only that transaction’s ops and masked every write. Distributed-data bugs are quiet, so this layer got extra tests on both sides of the cutover.
What went well
- Clippy is a design reviewer that never gets tired. A cleanup pass that burned down 47 warnings pushed real design improvements: boxing large error responses so
Resultvalues stay small, splitting handlers that had grown fat, deleting dead state. In Rust, the linter argues about data flow, and it is usually right. - Refactoring is mechanical instead of hopeful. Change a function signature and the compiler prints the exact list of callers to fix. Rename a type and nothing silently degrades. The compounding benefit is not fewer bugs on day one, it is refactors we no longer fear.
- The axum router reads like the API. One flat route table of path, method, handler, as plain functions. Django’s URLconf, views, serializers, and permission layers are each reasonable; the total sprawl was real. In Rust, the top of the HTTP crate is a readable inventory of the product.
- tokio::spawn is Celery eager mode with no broker. Where Django either enqueued a Celery task or hand-rolled its own async send, Rust detaches a task. Our email sender delivers on a fire-and-forget task that logs failures without blocking the request. Scheduled work lives in the
apps/workercrate, and WebSocket fan-out runs through Redis pub/sub with the same band-group semantics as the old Channels setup. - The test story came out stronger than the original. Golden contract tests as the acceptance spec; a diff-proxy soak that reached 17 of 17 GET paths byte-identical on the same database before anyone could trust the port; live HTTP smokes on staging. The webhook dedupe reuses Django’s billing event idempotency table, so replays drop exactly the way they always did.
When not to do this
- There is a working product and a deadline. A parity rewrite produces zero user-visible value until cutover; every day goes to reproducing behavior on purpose, behavior you dislike included. If you need features this quarter, migrate later, or never.
- Nobody on the team has Rust experience. The borrow checker, async lifetimes, tokio idioms: the ramp is real, and it repeats with every new teammate. If nobody will pay that repeatedly, do not.
- You live in Django admin. Budget the replacement explicitly, or keep Python. Admin replacement surprises people because it is invisible in the codebase and load-bearing in ops.
- The contract is not frozen, or you want to redesign anyway. If nothing pins your behavior, a rewrite quietly becomes a redesign in disguise, and that is how rewrites die. If redesigning anyway, a greenfield beats parity theater.
If you do go ahead, the ordering we would repeat: freeze the contract, write the contract tests before any port, run the diff proxy before you trust anything, and keep exactly one schema owner.
Where we are now
The rewrite is far along, not done, and we want to be precise about the seam. In production, the ingress serves both the app traffic and the WebSocket path from the Rust BFF, with the Django deployments scaled to zero and rollback manifests retained. The mobile contract has not changed by one key. The web portal is rebuilt in Rust, staged behind Playwright suites covering Stripe checkout, webhook signature verification and dedupe, login through band creation, and billing errors; staging is billing-wired for the Band Plan at $149 per year. The Band Plan itself is the product change that rode along rather than part of the rewrite: web checkout as the primary billing channel, the IAP as its mirror, and entitlement truth moved server-side into the band’s plan field, retiring a client-side honor system.
Still open: a final human checkout on staging, the production billing flip (untouched pending a decision), the remaining portal admin tooling, and deleting the Django codebase, the real finish line.
Would we do it again? For this codebase, yes. A bounded, contract-frozen rewrite of a well-tested surface was tractable, and the payoff is one binary for HTTP and WebSockets, visible SQL, and refactors we no longer fear. But the reason it worked was not Rust; it was the freeze, the tests, and reproducing behavior exactly, ugly parts included. Rust made the reproduction pleasant; the discipline made it possible.