Engineering · 2026.09.29 · 7 min · Updated 2026.10.26
Migrating From Java Spring Boot to Rust: Memory From 734 MiB to 32 MiB in One Cutover
My lending platform's backend went from Java 21 to Rust in one cutover with contracts and data intact: memory 734 MiB to 32 MiB, startup 30 s to about 0.03 s, API latency about the same.
Reading
My Spring Boot backend shared a 4-core ARM machine with Postgres, Redis, and monitoring, and the JVM alone sat at several hundred MB resident.
I rewrote the whole backend in Rust and switched over in a single cutover, with no period of running alongside Java. Afterward, container memory dropped from 734 MiB to 32 MiB, startup went from 30 seconds to about 0.03 seconds, and the image shrank from 433 MB to 62 MB.
But API response times differ by less than 1 ms, so users can't tell, and at first CI didn't get any faster either.
This lending platform moved to Rust four months after I rewrote it from a FastAPI script into Spring Boot. The code review I did after the switch is in the code review after the Rust port.
Why switch, and why not run both
STG and PROD each run on their own machine. I wanted faster deploys, lower resource use, and a less bloated project.
On the security side, the runtime image could become a distroless nonroot image, with no shell and no JVM. Whether CI would get faster I honestly didn't know, and planned to measure after the switch.
The usual approach is to run old and new side by side and shift traffic gradually with path routing or a shadow proxy.
This time I didn't run both. The Rust version was developed on a branch, STG was replaced wholesale for a soak, and PROD would follow once STG looked clean.
For a single cutover to work, two things couldn't change.
First, the external contract stays the same. API paths, JSON shapes, HTTP statuses, cookies, CSRF, metric names, and actuator paths all stay as they were, so not a single line of frontend or monitoring has to change.
Second, the data doesn't start over. Same Postgres and Redis: existing encrypted API keys must decrypt, password hashes must verify, and JWTs signed by Java must be accepted, so nobody has to log in again.

On the tech side, I used axum, sqlx, and rust_decimal, and wrote my own scheduler, circuit breaker, and Flyway-compatible migrator. The backend stays a single crate, because the Java packages had plenty of circular dependencies and splitting into crates would have stalled on them.
Throughout development I only pushed the branch and never opened a PR. Every workflow's push trigger only watches main, and pull_request only runs when a PR exists, so Actions usage for the whole development period was 0.
Diffing Java against Rust
Locally I ran the Java reference version and the Rust version side by side against the same anonymized DB. I sent identical requests, diffed the JSON, and compared numbers by value.
Of 175 GET pairs, 173 matched.
The differences came from a bug Java already had. One admin download path didn't match the URL the frontend actually called, so it always returned 404. The Rust version used the frontend's path, and downloads started working again.
Anything whose format has to match exactly got separate golden tests: the audit hash chain with real records as test vectors, and email templates compared character by character.
In the end all 121 routes matched, all 2,494 cargo test tests passed, and the Playwright E2E suite was fully green against Rust.
One thing tripped me up while diffing. The JVM ran in the +08 timezone and Rust in UTC, so APIs that group by date never matched.
The Java reference has to be started with TZ=UTC.
STG soak and PROD cutover
The original plan was a full 7-day soak on STG, which would conveniently cover both the monthly and weekly reports. The gates included every scheduled job succeeding at least once, 0 reconciliation drift, and 72 hours with no new Sentry errors. The Rust version also had to actually place at least one offer on STG and get it filled.
That last one took some waiting.
At cutover, the available balance in every lending config was below the minimum order size. The next day, two orders placed under Java expired and the funds returned to the wallet. The Rust version lent them back out, and both filled, with 0 errors from placement through reconciliation to the fill notification.
Once every path that moves money had been verified with real data on STG, I ended the soak early.
STG cut over on 9/28 and PROD on 9/29 during a maintenance window, and the branch was merged to main the same day.
After the merge, the Java image and the rollback compose file were deleted from the machines, with only DB backups kept. Going back to Java now would mean rebuilding it from git history.
Memory drops, latency doesn't move
Same code, same 1GiB memory limit:
| Java (Spring Boot) | Rust | |
|---|---|---|
| Container memory | 734 MiB | 32 MiB |
| Startup to accepting requests | 30.2 s | about 0.03 s |
| Image size | 433 MB | 62 MB |
| Full local test suite | about 6 min | about 35 s |
Comparing runtime on the same STG machine, average CPU dropped from 0.029 cores to 0.0014 cores, and process memory from 405 MiB to 46 MiB.
The biggest gap was the metrics endpoint Prometheus scrapes every 15 seconds. Java spent 356 ms each time assembling JVM and Micrometer meters; Rust took 1.2 ms.
Ordinary APIs spend most of their time in DB queries, so the gap there is under 1 ms, and one endpoint is actually slightly slower in Rust. Each lending engine cycle is almost entirely waiting on the Bitfinex API, so the language makes no difference there.
Treat these numbers as orders of magnitude only. Rust had been running for only about 7 hours, STG traffic is light, and the two sides measure CPU differently.
CI, though, didn't get faster. The backend job took 3 min 51 s, about the same as Java, and the first PR run took 11 and a half minutes and failed because the disk filled up. How I later got it down to a minute and a half is in speeding up Rust and Next.js CI on a 2 vCPU GitHub runner.
Top: the first run, 11 and a half minutes and failed. Bottom: 3 min 51 s later on.
Lessons from the port
Test cost moves around. Java tests mostly used Mockito and never touched the DB. Rust repositories are free functions with no DI, so nearly every test hits a real Postgres.
That's why almost all of the later CI speedup work ended up being about Postgres.
Metric behavior has to be compatible too. A Micrometer meter outputs 0 as soon as it's created, while the Rust metrics crate only emits a series after the first recorded value.
Prometheus's increase() misses the first value of a series, so the first circuit breaker failure after a restart went uncounted. Counters that alerts depend on need to be registered up front at startup.

A faithful port also ports old bugs. reqwest error messages include the full URL, and the Telegram bot API URL contains the token. The Java version was already writing it to the logs, and the port did the same.
It now calls without_url() before logging.
The easiest place to go wrong is porting semantics based on Java's comments and annotations.
What Java actually does isn't always what it says. The code review caught a REQUIRES_NEW that had never taken effect, where the Rust version followed the annotation and broke things. Details are in the code review after the Rust port.

Tools
Running Two LINE Accounts on One iPhone With LiveContainer: Three Free Apple ID Pitfalls
How I run a second LINE account on one iPhone with LiveContainer and SideStore, and how I got past running out of free App IDs, SideStore login failures, and Shortcut refresh errors.
Letters
Loading comments…