Migrating a Node.js Service to Rust in 2026: A Production Guide

Max WellsMax WellsFounder of Rustify

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 middleware

How 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 months

These 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 Rust unwrap() everywhere to avoid the borrow checker ship code that panics in production. Invest the time to implement thiserror and anyhow error propagation from day one.

  • Not setting up observability before migration: Moving from Node.js's rich console.log / winston ecosystem to Rust's tracing crate requires deliberate setup. Teams that migrate without setting up tracing-subscriber and 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 sccache or 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:

AspectNode.jsRustStatus
Health checkGET /health returns 200Same endpoint
Logging formatJSON to stdouttracing-subscriber JSON
Request IDsUUID header trackingtracing spans with ID
Error reportingSentry integrationSentry client initialized
MetricsPrometheus scrape endpoint/metrics Prometheus format
Database connectionsConnection pool configPgPool::connect with pool size
Secrets managementdotenv + ENV varsdotenvy + envy
Build timenpm install + buildcargo build --release (~3–5 min)
Binary sizeNode runtime + appSingle Rust binary (~20–40 MB)
CORS headersexpress-cors middlewaretower-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

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.


Sources

Ready to Land a $120k+ Rust Job in the US or Europe?