· 2 min read

Migrating from TypeScript to Rust

Migrating from TypeScript to Rust

A first-hand account of migrating customer-facing AWS Lambda endpoints from TypeScript to Rust. This post covers the motivation behind the migration, the Rust Lambda setup with Axum, key challenges encountered during the rewrite, and measurable performance improvements in cold start latency and overall API responsiveness. Includes code examples, OpenAPI integration, and before-and-after benchmark results.

If you have ever used AWS Lambda behind API Gateway, you know the pain of cold starts. A customer clicks a button, the API fires, and instead of an instant response, they sit there waiting. That lag kills user experience and no amount of frontend tricks can fully hide it.

Our TypeScript stack was simple: a Hono API with a plain Cognito authorizer. I loved it for small APIs and proofs-of-concept. Routing was trivial, OpenAPI docs just worked, and iteration was fast. But as usage grew, the places where customers actually felt latency became impossible to ignore.

OpenAPI Docs OpenAPI documentation remains up-to-date with Rust endpoints using utoipa, making it easy to maintain API contracts despite the rewrite.

I decided to migrate only the endpoints that mattered, those the customer personally interacted with. That meant two things: the Cognito authorizer, which runs on every request, and the API Gateway proxy function that handles incoming requests. Internal or admin endpoints did not need the rewrite because no one was waiting on them.

AWS does not provide a native Rust runtime yet, but the ecosystem has grown enough that running Rust Lambdas is feasible. I used aws-lambda-rust-runtime as the base runtime, with lambda-http for API Gateway proxy support, and Cargo Lambda to simplify building and deploying. For the main API, axum-aws-lambda allowed me to drop an Axum app straight into Lambda.

Cold Start Comparison Comparing cold start times for API Gateway proxy in TypeScript versus Rust. Rust significantly reduces latency for customer-facing requests.

The migration itself had surprises. In TypeScript with Hono, route setup was forgiving. Deployment stages and API Gateway prefixes did not matter. In Axum, they did. Routes suddenly failed to match, and I had to explicitly account for the stage prefix. After a few adjustments, everything started working.

Another hurdle was accessing the Lambda request context. In TypeScript, it was handed to me. In Axum, it was not obvious at all. After some digging, I found this GitHub issue, which explained how to inject RequestContext as an Axum extension. Here is what that looked like in a handler

pub async fn create_checkout_session_handler(
    State(app_state): State<AppState>,
    Extension(ctx): Extension<RequestContext>,
    Json(request): Json<CreateCheckoutSessionRequest>,
) -> impl IntoResponse {
    debug!("Creating checkout session for user");
    
    let user_id = match extract_user_id_or_unauthorized(&ctx) {
        Ok(id) => id,
        Err(error_response) => return error_response,
    };

    // business logic here
}

To extract the actual user ID from Cognito’s authorizer context, I wrote this helper

pub fn extract_user_id_from_context(ctx: &RequestContext) -> Option<String> {
    match ctx {
        RequestContext::ApiGatewayV1(_) => {
            if let Some(auth) = ctx.authorizer() {
                debug!("Authorizer fields: {:?}", auth.fields);

                if let Some(sub) = auth.fields.get("sub") {
                    return sub.as_str().map(|s| s.to_string());
                }

                if let Some(username) = auth.fields.get("username") {
                    return username.as_str().map(|s| s.to_string());
                }
            }
        }
        _ => {
            debug!("Non-API Gateway request context");
        }
    }
    None
}

This little function became a critical piece of the rewrite.

After everything was deployed, the results were clear. Cold starts that dragged in TypeScript were nearly invisible in Rust. Customers no longer waited, and the app felt instantaneous.

Of course, the tradeoff was on my side. Rust builds are heavier, Docker images bigger, and developer iteration slowed. But the runtime gains made it worth the wait. Customers save seconds on every request, seconds that matter far more than a few extra minutes waiting for a build.

I did not migrate everything, and I did not need to. TypeScript still handles internal endpoints perfectly well. But for the parts of the app that directly touch the customer, the authorizer and the API Gateway proxy, Rust made all the difference.

The migration was not frictionless. Routes required careful setup, context extraction took some figuring out, and builds are slow. But the result is fast, reliable endpoints that customers notice. That tradeoff is totally worth it.