← All posts

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
Theme
Size
Width

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.

Flow chart of the Rust migration in four steps: branch work with push only and no PR; a local diff sending the same requests to Java and Rust, with all 121 routes matching; STG swapped on 9/28 for a soak that ended early after a real fill; PROD cut over on 9/29 in a maintenance window and merged to main the same day, with the Java image deleted and only DB backups kept. The external contract and the data stay unchanged throughout.

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.

GitHub Actions job timing screenshot: the first run of Backend (cargo test) on the migration PR failed; a later run of the same job took 3 min 51 s. 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.

Illustration: after a restart, Java's Micrometer counter emits 0 from creation and steps to 1 at the first failure, so increase() counts 1; before the fix, the Rust series only appears at the first record with a value of 1, so increase() counts 0 and misses it; after registering the counter at startup, increase() counts 1 again.

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.

#Rust#Performance#Spring Boot#Backend Migration

Letters

Loading comments…

Next

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.