A practical migration guide for converting a production Node.js/Express REST API to Rust/Axum. Covers the decision framework, incremental migration strategy, mapping Express patterns to Axum, migrating database queries from Prisma/Sequelize to sqlx, and performance results.
By Rustify Team: Updated March 2026
TL;DR: Migrating a Node.js service to Rust is not just a rewrite: it's a careful tradeoff between developer productivity, performance, and operational cost. This guide covers the actual migration path: what to migrate, when to stop, and the Express → Axum pattern mapping you need to do it without surprises.
- Migration signal: if you're paying $5K+/month in compute and the service is CPU-bound, the ROI math works
- Incremental strategy: don't rewrite all at once: migrate behind a reverse proxy, route by route
- Express → Axum: handler functions, middleware, body parsing, error handling: all have direct equivalents
- Prisma/Sequelize → sqlx: raw SQL beats ORM complexity for Rust; sqlx adds compile-time query verification
- Timeline: a 5,000-line Node.js service typically takes 4–8 weeks for an experienced Rust developer
Who Should Read This?
This guide is for backend engineers and engineering managers who own Node.js/Express services in production and are evaluating whether rewriting in Rust is worth the investment. The ideal reader is a senior backend engineer earning $140K–$190K in the US, responsible for a service that is either becoming a cost center (high EC2 bills) or a reliability concern (GC-related latency spikes, memory leaks). You should already be comfortable with Node.js's async model and have at least passing familiarity with Rust. You do not need to be a Rust expert: this guide is designed so that a motivated engineer with 2–4 weeks of Rust experience can follow the migration end-to-end.
Is a Node.js to Rust Migration Worth It?
The migration is worth it when your service is CPU-bound and compute costs are measurable: if you're running 8 Node.js instances to handle load that 2 Rust instances could serve, the ROI math becomes clear within months.
Only migrate if the numbers justify the engineering cost:
Compute cost check:
─────────────────────────────────────────────────────────
Monthly EC2 bill: > $3,000 AND service is CPU-bound?
→ Migration ROI math likely works
Traffic: > 100,000 requests/day AND service is not I/O-bound?
→ Performance difference is meaningful
Team: Has at least one experienced Rust developer?
→ 3-4 months learning Rust while migrating doubles timeline
Alternative question: Is the bottleneck CPU or I/O?
─────────────────────────────────────────────────────────
If your Node.js service spends 80% of time waiting for database queries →
The bottleneck is I/O, not Node.js. Rewriting in Rust won't help much.
Fix: optimize queries, add caching, horizontal scaling.
If your service does: JSON parsing, data transformation, rule evaluation →
This is CPU-bound. Rust will be 5-15x faster. Migration ROI is real.What Is the Strangler Fig Migration Strategy?
The strangler fig pattern runs the Rust service in parallel with Node.js behind a reverse proxy, gradually moving traffic endpoint by endpoint: eliminating the risk of a big-bang rewrite.
Phase 1: Infrastructure (Week 1)
─────────────────────────────────────────────────────────
Run Rust service alongside Node.js
Configure nginx/Envoy to route by path:
/api/v1/users → Node.js
/api/v2/users → Rust (new endpoints only)
Zero risk: new endpoints in Rust, existing in Node.js
Phase 2: High-traffic endpoints (Weeks 2-4)
─────────────────────────────────────────────────────────
Migrate top 3 endpoints by traffic to Rust
Shadow mode: run both, compare responses
Route 5% → 50% → 100% traffic to Rust per endpoint
Phase 3: Full migration (Weeks 5-8)
─────────────────────────────────────────────────────────
Remaining endpoints
Node.js service decommissioned
Monitoring confirms parity
Nginx routing config:
location /api/v1/products {
proxy_pass http://rust-service:3001;
}
location /api/ {
proxy_pass http://node-service:3000;
}How Do You Map Express Patterns to Axum?
Direct equivalents: every Express pattern has an Axum counterpart:
// Express: basic handler
const express = require('express');
const app = express();
app.use(express.json());
app.get('/users/:id', async (req, res) => {
const { id } = req.params;
const user = await getUserById(parseInt(id));
if (!user) {
return res.status(404).json({ error: 'Not found' });
}
res.json(user);
});
// Express: middleware
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
next();
});// Axum: basic handler
use axum::{
extract::{Path, State},
http::StatusCode,
Json, Router,
routing::get,
response::IntoResponse,
};
async fn get_user(
State(db): State<PgPool>,
Path(id): Path<i64>,
) -> impl IntoResponse {
match get_user_by_id(&db, id).await {
Ok(Some(user)) => (StatusCode::OK, Json(user)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Not found" }))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// Axum: middleware via Tower
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/users/:id", get(get_user))
.with_state(db_pool)
.layer(TraceLayer::new_for_http()); // Request logging middlewareHow Do You Migrate Database Queries from Prisma to sqlx?
From Prisma/Sequelize to sqlx:
// Prisma (Node.js ORM)
const users = await prisma.user.findMany({
where: {
role: 'admin',
active: true,
},
select: {
id: true,
email: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 50,
});// sqlx: compile-time verified SQL (no ORM overhead)
use sqlx::PgPool;
use serde::Serialize;
#[derive(Debug, Serialize, sqlx::FromRow)]
struct User {
id: i64,
email: String,
created_at: chrono::DateTime<chrono::Utc>,
}
async fn get_admin_users(pool: &PgPool) -> sqlx::Result<Vec<User>> {
sqlx::query_as!(
User,
r#"
SELECT id, email, created_at
FROM users
WHERE role = 'admin' AND active = true
ORDER BY created_at DESC
LIMIT 50
"#,
)
.fetch_all(pool)
.await
}How Do You Migrate Error Handling?
// Express error handling
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({ error: err.message });
});
// Custom error class
class AppError extends Error {
constructor(message, status = 500) {
super(message);
this.status = status;
}
}// Axum error handling: implement IntoResponse
use axum::{http::StatusCode, Json, response::{IntoResponse, Response}};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Unauthorized")]
Unauthorized,
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Internal error: {0}")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
AppError::Database(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
// Handlers now use ? operator for clean error handling
async fn get_user(State(db): State<PgPool>, Path(id): Path<i64>) -> Result<Json<User>, AppError> {
let user = get_user_by_id(&db, id).await?; // Database error → AppError::Database
user.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))
.map(Json)
}What Performance Results Can You Expect?
Service: E-commerce product catalog API
─────────────────────────────────────────
Node.js/Express (before):
Instances: 8 × t3.medium (2 vCPU, 4 GB)
P99 latency: 85ms
Max throughput: ~800 req/s
Monthly EC2: $1,200
Rust/Axum (after):
Instances: 2 × t3.medium (same hardware)
P99 latency: 12ms
Max throughput: ~4,200 req/s
Monthly EC2: $300
Savings: $900/month, paid back 3-month migration in ~4 monthsThese numbers are from a real migration of a catalog service doing primarily JSON serialization and data transformation: a CPU-bound workload. Results vary by workload type. I/O-bound services (pure CRUD against Postgres) see smaller gains: typically 1.5x–3x rather than 5x–10x.
Testing Strategies During Migration
Before moving traffic to Rust, you need testing that covers both correctness and performance parity.
1. Unit testing with #[tokio::test]
Rust's tokio::test macro replaces Jest async tests:
#[tokio::test]
async fn test_get_user_success() {
let pool = create_test_db().await;
let user = get_user_by_id(&pool, 123).await.unwrap();
assert_eq!(user.id, 123);
assert_eq!(user.email, "[email protected]");
}
#[tokio::test]
async fn test_get_user_not_found() {
let pool = create_test_db().await;
let result = get_user_by_id(&pool, 99999).await;
assert!(result.unwrap().is_none());
}Each test gets its own connection pool that's rolled back after the test: equivalent to Jest's beforeEach/afterEach setup.
2. Integration testing against the live Rust service
Start a Rust server in test mode and make HTTP requests:
#[tokio::test]
async fn test_api_get_user_endpoint() {
let app = build_router(); // Your Axum router
let client = Client::new(app);
let response = client.get("/api/users/123").send().await;
assert_eq!(response.status(), StatusCode::OK);
let body: User = response.json().await;
assert_eq!(body.id, 123);
}This tests the full HTTP stack: routing, serialization, error handling: without needing to spin up a separate server.
3. Shadow testing: compare Node.js and Rust responses
Before cutover, run both services and compare responses on the same requests:
# Load a production request log
curl https://your-api.com/api/users/123 > requests.jsonl
# Route 50% to Node.js, 50% to Rust
# Compare responses (should be identical)
diff <(requests.jsonl | http-to-nodejs) <(requests.jsonl | http-to-rust)This catches subtle bugs: JSON serialization differences, null handling, timezone bugs, floating-point precision issues.
4. Performance testing with criterion.rs
Benchmark critical paths:
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn bench_log_parsing(c: &mut Criterion) {
let log_lines = vec![
"127.0.0.1 - - [01/Jan/2026:12:00:00 +0000] \"GET /api/users HTTP/1.1\" 200 128",
// ... thousands of lines
];
c.bench_function("parse_log_line", |b| {
b.iter(|| parse_log_line(black_box(&log_lines[0])))
});
}
criterion_group!(benches, bench_log_parsing);
criterion_main!(benches);Run with: cargo bench --release
This produces statistical output showing performance over time and detects regressions.
How Do You Handle Authentication and JWT in Axum?
JWT validation in Axum follows the middleware extractor pattern: define a custom extractor that validates the token on every request.
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String, // User ID
exp: usize, // Expiration timestamp
role: String,
}
struct AuthUser(Claims);
#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
let token = auth_header
.strip_prefix("Bearer ")
.ok_or((StatusCode::UNAUTHORIZED, "Invalid Authorization format"))?;
let secret = std::env::var("JWT_SECRET").unwrap_or_default();
let claims = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?
.claims;
Ok(AuthUser(claims))
}
}
// Usage in any handler: JWT validated automatically
async fn protected_handler(
AuthUser(claims): AuthUser,
State(db): State<PgPool>,
) -> Result<Json<serde_json::Value>, AppError> {
Ok(Json(serde_json::json!({ "user_id": claims.sub })))
}This pattern replaces Express's passport-jwt middleware with a zero-overhead extractor that integrates directly into Axum's type system.
Handling refresh tokens and token rotation
For services using refresh token rotation (issue a short-lived access token + long-lived refresh token):
#[derive(Debug, Serialize, Deserialize)]
struct Tokens {
access_token: String, // 15 min expiry
refresh_token: String, // 7 day expiry
}
async fn refresh_token_handler(
State(db): State<PgPool>,
Json(request): Json<RefreshRequest>,
) -> Result<Json<Tokens>, AppError> {
// Validate refresh token against database
let user_id = verify_refresh_token(&db, &request.refresh_token).await?;
// Issue new access + refresh tokens
let new_access = create_jwt_token(&user_id, Duration::minutes(15))?;
let new_refresh = create_refresh_token(&db, &user_id).await?;
Ok(Json(Tokens {
access_token: new_access,
refresh_token: new_refresh,
}))
}This matches Node.js's passport-jwt + refresh token flow with one key difference: Rust's type system enforces the presence of database validation, preventing the common mistake of forgetting to check token revocation in the database.
How Do You Manage Database Migrations and Schema Evolution?
Use sqlx migrate to manage schema changes consistently across both Node.js and Rust services during the transition period.
sqlx migrations are plain SQL files in a migrations/ directory:
sqlx migrate add create_users_table
# Creates: migrations/20260523120000_create_users_table.sql-- migrations/20260523120000_create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
role VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);Run migrations before starting the Rust service:
use sqlx::postgres::PgPoolOptions;
#[tokio::main]
async fn main() {
let pool = PgPoolOptions::new()
.connect(&database_url)
.await
.expect("Failed to connect");
sqlx::migrate!()
.run(&pool)
.await
.expect("Migration failed");
// Now safe to use the database
let app = build_router(pool);
// ...
}The key advantage: migrations run the same way in both Node.js and Rust. Your Sequelize/TypeORM migrations can coexist with sqlx migrate during the transition, ensuring the schema stays in sync regardless of which service executes the migration. During strangler fig migration, run migrations before traffic shifts to avoid schema mismatches.
How Do You Migrate Configuration and Environment Variables?
Replace Node.js's dotenv and process.env with the dotenvy and config crates, and deserialize environment variables directly into typed Rust structs.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Config {
pub database_url: String,
pub jwt_secret: String,
pub port: u16,
pub redis_url: Option<String>,
}
impl Config {
pub fn from_env() -> Result<Self, envy::Error> {
dotenvy::dotenv().ok(); // Load .env file if present
envy::from_env::<Config>()
}
}
// In main():
let config = Config::from_env().expect("Invalid configuration");The envy crate deserializes environment variables into typed structs, giving you compile-time guarantees that your configuration is complete: unlike process.env.SOME_VAR which silently returns undefined in Node.js.
What Do Rust Engineers Who Do Node.js Migrations Earn?
Senior engineers who specialize in systems rewrites and Rust migrations are compensated at the higher end of backend engineering: $175K–$230K total compensation in the US is typical for senior Rust engineers who can own this kind of infrastructure work end-to-end. The combination of Rust depth, systems knowledge, and proven production migration experience is rare enough that companies pay to retain it.
What Common Mistakes Do Developers Make When Migrating Node.js to Rust?
The most damaging mistake is attempting a full rewrite all at once: without the strangler fig pattern, you go weeks without deployable software and lose the ability to compare behavior against the production system.
Bottom line: Use the strangler fig pattern: route endpoints to Rust one at a time, verify behavior against Node.js in production, then retire the old code. This eliminates the risk of a big-bang rewrite that introduces subtle bugs only visible in production. The 4–8 week timeline is realistic only if you can compare Rust behavior against Node.js in parallel.
-
Big-bang rewrites: Trying to rewrite the entire service before deploying any Rust code is the single most common failure mode. Without the ability to compare Node.js and Rust responses in production, subtle behavioral differences (edge cases in JSON serialization, different null handling, timezone quirks) go undetected until cutover, when they cause incidents.
-
Migrating I/O-bound services expecting a 10x speedup: If your service spends 80% of request time waiting on Postgres, rewriting in Rust yields modest gains: maybe 1.5x throughput and 30% latency reduction. The engineers who report disappointment after migration almost always had I/O-bound workloads. Profile before you commit.
-
Trying to replicate Prisma's ORM API in Rust: Rust's sqlx works best with plain SQL. Engineers who invest time building an ORM-like abstraction layer end up with more complexity than the Prisma code they replaced. Embrace raw SQL; sqlx's compile-time verification gives you safety without the ORM overhead.
-
Ignoring the borrow checker during error handling: Node.js error handling uses exceptions; Rust uses
Result<T, E>. Developers who write Rustunwrap()everywhere to avoid the borrow checker ship code that panics in production. Invest the time to implementthiserrorandanyhowerror propagation from day one. -
Not setting up observability before migration: Moving from Node.js's rich
console.log/winstonecosystem to Rust'stracingcrate requires deliberate setup. Teams that migrate without setting uptracing-subscriberand shipping logs to their existing log aggregator fly blind during the critical first weeks in production. -
Underestimating compile times in CI: Rust's compile time is a real CI bottleneck. A 5,000-line Rust service may take 4–8 minutes to build from scratch in CI. Set up
sccacheor GitHub Actions caching for the target directory from day one, or CI becomes a constant frustration.
Observability and Monitoring in the Migrated Service
The migration is not complete until you have feature parity in observability: logging, metrics, tracing, and error reporting must match your Node.js setup.
A common mistake: starting the Rust service without equivalent observability, then flying blind during the critical first weeks. Here's what you need:
1. Structured logging with tracing and tracing-subscriber
Node.js uses winston/pino; Rust uses tracing. Set up JSON logging to match your Node.js format:
use tracing_subscriber::fmt;
fn main() {
// JSON output to stdout (compatible with ECS, Datadog, CloudWatch)
tracing_subscriber::fmt()
.json()
.with_max_level(Level::INFO)
.init();
// Now all your handlers automatically log:
tracing::info!(user_id = %id, "User fetch succeeded");
tracing::error!(error = %err, "Database query failed");
}This produces logs in the same format as your Node.js app: no parser changes needed in your log aggregator.
2. Metrics collection with prometheus and metrics crate
use axum_prometheus::PrometheusMetricLayer;
let (prometheus_layer, metrics_handler) = PrometheusMetricLayer::pair();
let app = Router::new()
.route("/metrics", get(metrics_handler))
.layer(prometheus_layer);Exposes /metrics in Prometheus format: compatible with your existing Prometheus → Grafana pipeline. No changes to monitoring infrastructure needed.
3. Tracing with distributed IDs
Keep the same request ID scheme as your Node.js app:
use uuid::Uuid;
async fn middleware_request_id(
mut request: Request,
next: Next,
) -> Response {
let request_id = Uuid::new_v4().to_string();
request.extensions_mut().insert(request_id.clone());
// Attach to all logs in this request
let _guard = tracing::info_span!("request", %request_id).enter();
next.run(request).await
}This traces individual requests end-to-end: same capability as your Express middleware.
4. Error reporting with sentry or your existing bug tracker
let _guard = sentry::init("https://[email protected]/project");
async fn handler() -> Result<Json<Response>, AppError> {
let result = risky_operation().await?;
// Errors automatically reported to Sentry: no code change
Ok(Json(result))
}Production Deployment Checklist
Before cutover, verify these items:
| Aspect | Node.js | Rust | Status |
|---|---|---|---|
| Health check | GET /health returns 200 | Same endpoint | ✓ |
| Logging format | JSON to stdout | tracing-subscriber JSON | ✓ |
| Request IDs | UUID header tracking | tracing spans with ID | ✓ |
| Error reporting | Sentry integration | Sentry client initialized | ✓ |
| Metrics | Prometheus scrape endpoint | /metrics Prometheus format | ✓ |
| Database connections | Connection pool config | PgPool::connect with pool size | ✓ |
| Secrets management | dotenv + ENV vars | dotenvy + envy | ✓ |
| Build time | npm install + build | cargo build --release (~3–5 min) | ✓ |
| Binary size | Node runtime + app | Single Rust binary (~20–40 MB) | ✓ |
| CORS headers | express-cors middleware | tower-http::cors layer | ✓ |
Want a Structured Path to Learning Rust for Backend Work?
If you're planning a migration and want to build solid Rust fundamentals before starting, Rustify's 9-week bootcamp covers Axum, sqlx, error handling, and production deployment patterns with 1:1 coaching. Engineers who complete the program have the skills to lead a Node.js-to-Rust migration independently.
Keep Reading
- Rust Developer Salary in the USA (2026)
- How Long to Learn Rust by Background: Hours, Timeline & ROI
- Best Way to Learn Rust in 2026
- Is Rust Hard to Learn?
Frequently Asked Questions
No: if your service spends most time waiting for database queries or external API calls, Node.js is already efficient (single-threaded event loop handles I/O well). Rewriting in Rust won't help. Fix the I/O bottleneck instead (query optimization, caching).
Replace dotenv + process.env with the dotenvy + envy crates in Rust. config crate handles layered configuration (env vars, files, defaults). The pattern is very similar: load environment, deserialize into a typed config struct.
Axum supports WebSockets via axum::extract::ws::WebSocketUpgrade and SSE via axum::response::sse::Sse. The patterns are similar to Express + ws/socket.io: the main difference is Rust's async model (no event loop confusion).
Axum handles multipart form data via the axum-multipart crate or the multer crate. The pattern is similar to Express's multer: parse the multipart body, extract named fields, process bytes. For large uploads, stream directly to S3 using the AWS SDK rather than buffering in memory.
Rust's built-in #[test] with tokio::test for async tests replaces Jest. For integration testing HTTP endpoints, use axum::test (which ships a test client) or reqwest against a locally spawned server. The sqlx::test attribute manages test database transactions, rolling back after each test: equivalent to Jest's beforeEach/afterEach database cleanup.
Use the tower-sessions crate for server-side sessions with Axum: it integrates with Redis or PostgreSQL backends and works as a Tower middleware layer. JWT-based auth (stateless) is often simpler for Rust backends; the custom extractor pattern in this article handles it with no external crate for the auth logic itself.
No: sqlx queries against the same Postgres schema your Node.js app used. The migration is purely at the application layer. Schema changes are a separate concern and can be handled with sqlx's built-in migration runner (sqlx migrate run), which is the Rust equivalent of Sequelize migrations.
For a team starting from zero Rust knowledge: 4–8 weeks of Rust learning before touching production code, then 6–12 weeks for the migration itself, depending on service complexity. A 5,000-line Node.js service with an experienced Rust developer takes 4–8 weeks. The same service with a team learning Rust simultaneously takes 3–4 months. Plan accordingly.
