Web Server #

The Rust web ecosystem has grown rapidly and now has several mature frameworks ready for production. The three most popular are axum (built by the tokio team, ergonomic and modular), actix-web (highest performance in benchmarks, actor-based), and rocket (most beginner-friendly, lots of built-in features). This article covers all three with fairly in-depth examples to get you started — routing, JSON, shared state, middleware, and error handling — and then ends with a deployment guide with Docker.

Framework Comparison #

flowchart TD
    Q{Prioritas utama?}
    Q --> P["Performa\nabsolut tertinggi"]
    Q --> E["Ergonomis\ndan modular"]
    Q --> B["Ramah pemula\nfitur built-in"]

    P --> AW["actix-web\nBerbasis aktor\nPerforma benchmark tertinggi\nEkosistem sangat kaya"]
    E --> AX["axum\nDibuat tim tokio\nExtractor pattern elegan\nMiddleware via tower"]
    B --> RK["rocket\nMakro yang ekspresif\nValidasi request otomatis\nAsync native di v0.5"]
Aspectaxumactix-webrocket
Stable version0.74.x0.5
Async runtimeTokyotokio (custom)Tokyo
PerformanceVery highHighestHeight
Learning curveMediumMediumLow
MiddlewareTower ecosystemActix middlewareFairings
Suitable forAPIs, microservicesHigh-performance APIFullstack web

Axum — Ergonomic and Modular #

Installation #

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }

Basic Server with Routing #

use axum::{
    extract::{Path, Query},
    http::StatusCode,
    response::Json,
    routing::{delete, get, post, put},
    Router,
};
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct Respons {
    pesan: String,
    status: u16,
}

// Simple handler
async fn root() -> &'static str {
    "Selamat datang di API Rust!"
}

// Path parameters
async fn salam(Path(nama): Path<String>) -> String {
    format!("Halo, {}!", nama)
}

// Query string: GET /search?word=rust&page=2
#[derive(Deserialize)]
struct ParameterCari {
    kata: String,
    halaman: Option<u32>,
}

async fn cari(Query(params): Query<ParameterCari>) -> Json<Respons> {
    let pesan = format!(
        "Mencari '{}' di halaman {}",
        params.kata,
        params.halaman.unwrap_or(1)
    );
    Json(Respons { pesan, status: 200 })
}

// Multiple path parameters
async fn artikel_berdasarkan_kategori(
    Path((kategori, id)): Path<(String, u64)>,
) -> String {
    format!("Kategori: {}, ID artikel: {}", kategori, id)
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(root))
        .route("/salam/:nama", get(salam))
        .route("/cari", get(cari))
        .route("/artikel/:kategori/:id", get(artikel_berdasarkan_kategori));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server di http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

JSON Request and Response #

use axum::{extract::Json, http::StatusCode, response::IntoResponse, routing::post, Router};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct RequestBuatPengguna {
    nama: String,
    email: String,
    usia: u8,
}

#[derive(Serialize)]
struct ResponsPengguna {
    id: u64,
    nama: String,
    email: String,
}

async fn buat_pengguna(
    Json(body): Json<RequestBuatPengguna>,
) -> (StatusCode, Json<ResponsPengguna>) {
    // Simple validation
    if body.nama.is_empty() || body.email.is_empty() {
        return (
            StatusCode::BAD_REQUEST,
            Json(ResponsPengguna {
                id: 0,
                nama: String::from("error"),
                email: String::from("nama dan email wajib diisi"),
            }),
        );
    }

    // Simulation save to database
    let pengguna = ResponsPengguna {
        id: 1001,
        nama: body.nama,
        email: body.email,
    };

    (StatusCode::CREATED, Json(pengguna))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/pengguna", post(buat_pengguna));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Shared State in Axum #

use axum::{extract::State, routing::get, Router};
use std::sync::{Arc, Mutex};

// Application state shared among all handlers
#[derive(Clone)]
struct AppState {
    counter: Arc<Mutex<u64>>,
    nama_app: String,
}

async fn ambil_counter(State(state): State<AppState>) -> String {
    let c = state.counter.lock().unwrap();
    format!("{}: counter = {}", state.nama_app, *c)
}

async fn increment(State(state): State<AppState>) -> String {
    let mut c = state.counter.lock().unwrap();
    *c += 1;
    format!("Counter sekarang: {}", *c)
}

#[tokio::main]
async fn main() {
    let state = AppState {
        counter: Arc::new(Mutex::new(0)),
        nama_app: String::from("Aplikasi Rust"),
    };

    let app = Router::new()
        .route("/counter", get(ambil_counter))
        .route("/increment", get(increment))
        .with_state(state);  // state injection to all routes

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Error Handling on Axum #

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

// Custom error type
enum AppError {
    TidakDitemukan(String),
    ServerError(String),
    BadRequest(String),
}

// Implement IntoResponse so that it can be returned from the handler
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, pesan) = match self {
            AppError::TidakDitemukan(msg) => (StatusCode::NOT_FOUND, msg),
            AppError::ServerError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
        };

        let body = Json(json!({
            "error": pesan,
            "status": status.as_u16()
        }));

        (status, body).into_response()
    }
}

async fn cari_pengguna(axum::extract::Path(id): axum::extract::Path<u64>)
    -> Result<Json<serde_json::Value>, AppError>
{
    if id == 0 {
        return Err(AppError::BadRequest("ID tidak boleh 0".into()));
    }

    // Simulation: only IDs 1–100 exist
    if id > 100 {
        return Err(AppError::TidakDitemukan(format!("Pengguna ID {} tidak ada", id)));
    }

    Ok(Json(json!({
        "id": id,
        "nama": format!("Pengguna {}", id),
        "email": format!("user{}@contoh.com", id)
    })))
}

Actix-web — Highest Performance #

Installation #

[dependencies]
actix-web = "4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Server with Routing and JSON #

use actix_web::{
    delete, get, post, put,
    web::{self, Data, Json, Path, Query},
    App, HttpResponse, HttpServer, Responder,
};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;

#[derive(Serialize, Deserialize, Clone)]
struct Produk {
    id: u64,
    nama: String,
    harga: f64,
}

struct AppState {
    produk: Mutex<Vec<Produk>>,
}

// GET /products — retrieve all products
#[get("/produk")]
async fn daftar_produk(state: Data<AppState>) -> impl Responder {
    let produk = state.produk.lock().unwrap();
    HttpResponse::Ok().json(produk.clone())
}

// GET /product/{id}
#[get("/produk/{id}")]
async fn ambil_produk(path: Path<u64>, state: Data<AppState>) -> impl Responder {
    let id = path.into_inner();
    let produk = state.produk.lock().unwrap();

    match produk.iter().find(|p| p.id == id) {
        Some(p) => HttpResponse::Ok().json(p),
        None => HttpResponse::NotFound().json(serde_json::json!({
            "error": format!("Produk {} tidak ditemukan", id)
        })),
    }
}

// POST /product — create a new product
#[post("/produk")]
async fn buat_produk(body: Json<Produk>, state: Data<AppState>) -> impl Responder {
    let mut produk = state.produk.lock().unwrap();
    produk.push(body.into_inner());
    HttpResponse::Created().json(produk.last().unwrap())
}

// DELETE /product/{id}
#[delete("/produk/{id}")]
async fn hapus_produk(path: Path<u64>, state: Data<AppState>) -> impl Responder {
    let id = path.into_inner();
    let mut produk = state.produk.lock().unwrap();
    let panjang_awal = produk.len();
    produk.retain(|p| p.id != id);

    if produk.len() < panjang_awal {
        HttpResponse::Ok().json(serde_json::json!({"dihapus": id}))
    } else {
        HttpResponse::NotFound().body("Tidak ditemukan")
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let state = Data::new(AppState {
        produk: Mutex::new(vec![
            Produk { id: 1, nama: "Laptop".into(), harga: 15_000_000.0 },
            Produk { id: 2, nama: "Mouse".into(), harga: 250_000.0 },
        ]),
    });

    println!("Server actix-web di http://localhost:8080");
    HttpServer::new(move || {
        App::new()
            .app_data(state.clone())
            .service(daftar_produk)
            .service(ambil_produk)
            .service(buat_produk)
            .service(hapus_produk)
    })
    .bind("0.0.0.0:8080")?
    .run()
    .await
}

Middleware in Actix-web #

use actix_web::{
    dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
    middleware::Logger,
    web, App, Error, HttpServer,
};
use std::future::{ready, Future, Ready};
use std::pin::Pin;
use std::time::Instant;

// Middleware logging request processing time
pub struct TimingMiddleware;

impl<S, B> Transform<S, ServiceRequest> for TimingMiddleware
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type InitError = ();
    type Transform = TimingService<S>;
    type Future = Ready<Result<Self::Transform, Self::InitError>>;

    fn new_transform(&self, service: S) -> Self::Future {
        ready(Ok(TimingService { service }))
    }
}

pub struct TimingService<S> {
    service: S,
}

impl<S, B> Service<ServiceRequest> for TimingService<S>
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;

    forward_ready!(service);

    fn call(&self, req: ServiceRequest) -> Self::Future {
        let mulai = Instant::now();
        let metode = req.method().to_string();
        let path = req.path().to_string();
        let fut = self.service.call(req);

        Box::pin(async move {
            let resp = fut.await?;
            println!("{} {} selesai dalam {:?}", metode, path, mulai.elapsed());
            Ok(resp)
        })
    }
}

Rocket — Beginner Friendly #

Installation #

[dependencies]
rocket = { version = "0.5", features = ["json"] }
serde = { version = "1", features = ["derive"] }

Server with Routing, JSON, and State #

#[macro_use]
extern crate rocket;

use rocket::{
    serde::{json::Json, Deserialize, Serialize},
    State,
};
use std::sync::Mutex;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
struct Catatan {
    id: u64,
    judul: String,
    isi: String,
}

struct DbCatatan {
    daftar: Mutex<Vec<Catatan>>,
    id_berikutnya: Mutex<u64>,
}

// GET / — main page
#[get("/")]
fn index() -> &'static str {
    "API Catatan dengan Rocket"
}

// GET /records — all records
#[get("/catatan")]
fn daftar_catatan(db: &State<DbCatatan>) -> Json<Vec<Catatan>> {
    Json(db.daftar.lock().unwrap().clone())
}

// GET /notes/<id>
#[get("/catatan/<id>")]
fn ambil_catatan(id: u64, db: &State<DbCatatan>) -> Option<Json<Catatan>> {
    let daftar = db.daftar.lock().unwrap();
    daftar.iter().find(|c| c.id == id).cloned().map(Json)
}

// POST /note
#[post("/catatan", data = "<catatan>")]
fn buat_catatan(catatan: Json<Catatan>, db: &State<DbCatatan>) -> Json<Catatan> {
    let mut id_counter = db.id_berikutnya.lock().unwrap();
    let mut daftar = db.daftar.lock().unwrap();

    let baru = Catatan {
        id: *id_counter,
        judul: catatan.judul.clone(),
        isi: catatan.isi.clone(),
    };
    *id_counter += 1;
    daftar.push(baru.clone());
    Json(baru)
}

// DELETE /notes/<id>
#[delete("/catatan/<id>")]
fn hapus_catatan(id: u64, db: &State<DbCatatan>) -> &'static str {
    let mut daftar = db.daftar.lock().unwrap();
    let panjang = daftar.len();
    daftar.retain(|c| c.id != id);
    if daftar.len() < panjang { "Berhasil dihapus" } else { "Tidak ditemukan" }
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .manage(DbCatatan {
            daftar: Mutex::new(Vec::new()),
            id_berikutnya: Mutex::new(1),
        })
        .mount("/", routes![index, daftar_catatan, ambil_catatan, buat_catatan, hapus_catatan])
}

Deployment with Docker #

Multi-stage Dockerfile for lightweight Rust binaries:

# Stage 1: Build
FROM rust:1.77-slim as builder

WORKDIR /app
COPY Cargo.toml Cargo.lock ./
# Build dependensi dulu (di-cache) agar rebuild lebih cepat
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm src/main.rs

# Copy source dan build ulang hanya source
COPY src ./src
RUN touch src/main.rs && cargo build --release

# Stage 2: Runtime — image sangat kecil
FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/nama-aplikasi /usr/local/bin/

EXPOSE 3000
CMD ["nama-aplikasi"]
# Build image
docker build -t rust-webserver .

# Jalankan container
docker run -p 3000:3000 rust-webserver

# Dengan environment variable
docker run -p 3000:3000 -e DATABASE_URL=postgres://... rust-webserver

Binary Rust compiled with --release and strip = true (in Cargo.toml) can be as small as 2–5 MB, making the final container very light.


Summary #

  • Choose a framework based on priorityaxum for modern APIs that are ergonomic and well integrated with the tower ecosystem, actix-web for maximum performance, rocket for ease of development.
  • All frameworks support JSON via serdeJson<T> as extractor automatically deserialize request body; Json<T> as return type automatically serializes response.
  • Shared state with Arc<Mutex<T>> — inject to all handlers via State (axum) or Data (actix-web) or State rocket. For read-many-write-few access, consider RwLock.
  • Handler in axum returns impl IntoResponse — various types can be returned: string, StatusCode, tuple, Json, and custom types that implement IntoResponse.
  • Error handling in axum via Result<T, E> — create an error enum and implement IntoResponse, then the handler can return Result<Json<T>, AppError>.
  • Middleware on axum via tower — use tower-http for CORS, request logging, timeouts, and compression easily: .layer(CorsLayer::permissive()).
  • Multi-stage Docker for small binaries — build on a complete Rust image, copy binary to debian:slim or even scratch. Final result 5–20 MB vs 1+ GB image builder.
  • Actix-web #[get], #[post], etc. is a proc-macro that defines HTTP routes and methods at once — simpler than manual registration.


← Previous: Web Socket   Next: Unit Test →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact