Why I Migrated Sendryx's Payments Backend from NestJS to Rust
Migrating the Sendryx payments backend from NestJS to Rust: 250 MB to 20 MB resident memory, the hosting cost that saved, transfers from roughly 850 ms to 300 ms, type safety at the money boundary, and compile-time checked SQL.
14 February 20268 min read
- Published
- Reading time
- Tags
- rustpaymentsarchitecture
Sendryx is a remittance platform. Users send money, I settle it, and a payout lands in a Nigerian bank account. The code that touches a user's balance is the code I want to be most defensive about, which is the whole reason I rewrote it from NestJS to Rust.
This is what the migration involved, why I made each trade-off, and which parts of it I would do differently.
What the service actually does
Before the trade-offs, the shape of the thing. The payments core is responsible for wallets, stablecoin payments, automated NGN payouts and real-time fiat settlement, plus the asynchronous workers, payment processing flows, webhooks and monitoring that keep it reliable. It runs on PostgreSQL, and it settles on-chain across Solana and Ethereum.
Four responsibilities, and they have very different failure modes:
- Synchronous request handling. The
POST /transferspath. Latency-sensitive, and every branch is a place to lose money or double-charge someone. - Asynchronous work. Payout dispatch, webhook fan-out, settlement reconciliation. Not latency-sensitive, but every job must be durable enough to survive a restart.
- Settlement. Reconciling what the chain says against what the ledger says. This is where compliance requirements and transaction controls live.
- On-chain operations. Wallet operations and transaction tracking across Solana and Ethereum.
A single runtime with a garbage collector has to be good at all four. That was the tension.
The case for Rust, honestly assessed
I want to be careful here, because "Rust is faster" is not an argument. It is faster, and for a payments API that is close to irrelevant on its own. The arguments that actually decided it:
The money boundary became a type instead of a convention. I knew money should never be a float. In TypeScript that guarantee lived in a convention that four files had to remember, and nothing enforced it. In Rust it is a type that cannot be constructed incorrectly, and the compiler holds it in place everywhere.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MinorUnits {
value: i128,
currency: Currency,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Currency {
Ngn,
Usd,
Sol,
Eth,
}
impl MinorUnits {
pub fn add(self, other: Self) -> Result<Self, MoneyError> {
if self.currency != other.currency {
return Err(MoneyError::CurrencyMismatch {
left: self.currency,
right: other.currency,
});
}
Ok(Self {
value: self.value + other.value,
currency: self.currency,
})
}
}Adding NGN to SOL is now an explicit domain error rather than an unchecked arithmetic operation. The second effect
matters just as much: because Currency is an enum, adding a currency later produces a wall
of compiler errors instead of a silent fallthrough to a default match arm. In TypeScript, an
exhaustive check is a lint suggestion. Here it is the compiler.
Compile-time checked SQL. This is the part I would not give back. SQLx verifies the query against the real schema while compiling.
sqlx::query_as!(
LedgerRow,
r#"
SELECT id, amount_minor, currency, kind, created_at
FROM ledger_entries
WHERE wallet_id = $1
AND created_at >= $2
ORDER BY created_at DESC
LIMIT $3
"#,
wallet_id,
since,
limit as _
)
.fetch_all(pool)
.await?;Rename a column and the build fails rather than the query failing on a Sunday afternoon. The cost is real and worth stating: no lazy loading, no query builder, and migrations have to be applied before you compile.
No garbage collector in the asynchronous path. Workers that run all night, wake on a webhook and hold a payment context should not have unpredictable pauses in them. For long-lived workers this is the property I care about, and it is a correctness-adjacent property rather than a performance one.
What I gave up
None of this is free, and an honest account has to say what the cost was.
- Iteration speed. Adding an endpoint in NestJS was a decorator and a return type. In Axum it is an extractor, a handler signature and a router entry. Slower for the first hour, roughly equal afterwards.
- The ecosystem. Express middleware, guards and the DI graph are mature and ergonomic. Axum
gives you
FromRequestParts,FromRequestandState, which is less ceremony, but it is ceremony you can hold in your head. - My own familiarity. The team was not Rust-only. That is a real operational cost and the main argument I hear against the rewrite.
- Wallet and RPC libraries. The blockchain tooling is still JavaScript-first. Some of it stays on the Node side.
Where async work should live
The most important change was not a language change. It was moving retry and job state out of process memory and into the database.
Any in-memory queue of pending work is a queue that dies with the process. If a job is only represented by a JavaScript object, then the payout owed to a customer exists in exactly one place, and that place is a heap that can be garbage collected. Writing the job as a row means a worker on the next boot can find it.
pub async fn create_transfer(
State(state): State<AppState>,
auth: AuthContext,
Json(body): Json<CreateTransferRequest>,
) -> Result<(StatusCode, Json<TransferResponse>), ApiError> {
let transfer = state
.transfers
.create(auth.user_id, body)
.await
.map_err(ApiError::from)?;
// The transfer is committed before any worker ever sees it.
state.jobs.enqueue(PayoutJob::from(&transfer)).await?;
Ok((StatusCode::CREATED, Json(TransferResponse::from(transfer))))
}enqueue writes a row. There is no array, no closure, no retry state in memory. This is the
kind of change I would have made first, in any language, and the fact that it arrived bundled
with a language migration is a sequencing mistake on my part.
The broader principle is the outbox pattern: write the state change and the notification in one transaction, so they cannot disagree with each other. If the queue and the ledger are separate writes, there is a window where one succeeded and the other did not, and that window is a support ticket.
Side effects get harder on purpose
In NestJS, any service could reach any other service, and nothing in the type system stopped a payment handler from quietly calling the database. In Rust the persistence boundary is a trait, so a component only has access to what it was handed.
That is more ceremony, and it is the point. For the settlement path in particular, I want the set of things that code can touch to be small, obvious, and reviewable. Webhook fan-out used to be able to reach the ledger directly; now it cannot.
The second place this shows up is the outbound webhook payload. In the TypeScript version it was a plain object any module could reshape. In Rust it is a serialised enum, so adding a field means changing consumers deliberately rather than discovering later that a field disappeared.
How I would sequence it again
If you are facing this decision, this is the order I would run it in, which is not the order I ran it in:
- Make the durable state correct first. Ledger tables, job tables, retry columns, idempotency keys. In whatever language you are already using. This is the part that actually protects users, and it is independent of the rewrite.
- Introduce the outbox. One transaction for the state change and the notification.
- Run both stacks against the same Postgres. Shadow the read path, with a flag to cut back.
- Move one write path at a time, smallest blast radius first: payouts, then transfers, then webhooks.
- Do not reintroduce an ORM in the new service. Lazy loading is the thing that will keep the new stack slow, and it is much easier to never add than to remove later.
The numbers, and how much I trust them
Two results worth reporting, and they come with very different levels of confidence. I am going to be explicit about which is which, because a number you cannot verify is worth less than no number at all.
Memory: the one I would put my name on
The NestJS service sat at over 250 MB resident. The Rust service for the same work sits at around 20 MB. That is more than a 90% reduction, and it is the figure I find hardest to argue with, because it is not a synthetic benchmark result. It is what my own server reports while the service is doing real work.
Getting from 250 MB to 20 MB is not an abstract win. It meant I could stop paying for memory I was not using, and that is where the cost reduction came from. The service fits comfortably on a smaller instance, so I moved it down, and the hosting bill came down with it.
For a small company, infrastructure is a fixed monthly cost whether or not anyone sends a transfer. A memory footprint is one of the few levers on it that engineering actually controls, which is a large part of why I think about it the way I do.
The mechanism is not mysterious. No garbage collector means no heap that grows and is occasionally collected, no allocator fragmentation, and no runtime holding onto a peak. The process allocates what it needs and gives the pages back. On a long-lived worker that is the whole difference between a footprint that climbs and one that stays flat.
Latency: directionally right, not properly measured
Transaction processing got faster. Before the migration a transfer was taking somewhere in the 700 to 850 ms range. After, the same request is landing between 150 and 250 ms. That is roughly a 2.5x to 3x improvement on the endpoint, and it is the number I get asked about most often.
So take it for what it is: evidence that the rewrite did not make things slower and probably made them meaningfully faster, with the actual number carrying a wide error bar. If you are deciding whether to migrate your own service on the strength of this post, do not use the multiple. Use the memory figure, or run your own measurement.
There is a useful detail in the shape of that improvement, though. Transfer requests are dominated by waiting on things that are not JavaScript or Rust: database round trips, RPC calls to Solana and Ethereum nodes, and settlement calls to payout providers. Application code is a small share of the wall clock on that path. So the runtime choice moving the number by 2.5x to 3x says less about Rust being fast than it says about how much of the original request was waiting rather than computing.
That is the more useful conclusion, and it is the one I would have reached sooner if I had profiled before rewriting. The framework was never the bottleneck on this endpoint. Measure where your request actually spends its time before you pick a language.
What it bought, without a benchmark
Memory was the number that decided the business case. Latency is encouraging but unverified. The rest is qualitative, and I am not going to dress it up as measurement:
- Adding two different currencies in one expression is a compile error.
- Renaming a ledger column breaks the build.
- A queued payout survives a restart because it is a row.
- The asynchronous worker has no garbage collector pauses to reason about.
The last two are the ones that matter for a system that moves money. The language change was the vehicle; durability and types were the destination.