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"]| Aspect | axum | actix-web | rocket |
|---|---|---|---|
| Stable version | 0.7 | 4.x | 0.5 |
| Async runtime | Tokyo | tokio (custom) | Tokyo |
| Performance | Very high | Highest | Height |
| Learning curve | Medium | Medium | Low |
| Middleware | Tower ecosystem | Actix middleware | Fairings |
| Suitable for | APIs, microservices | High-performance API | Fullstack 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 priority —
axumfor modern APIs that are ergonomic and well integrated with the tower ecosystem,actix-webfor maximum performance,rocketfor ease of development.- All frameworks support JSON via serde —
Json<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 viaState(axum) orData(actix-web) orStaterocket. For read-many-write-few access, considerRwLock.- Handler in axum returns
impl IntoResponse— various types can be returned: string, StatusCode, tuple, Json, and custom types that implementIntoResponse.- Error handling in axum via
Result<T, E>— create an error enum and implementIntoResponse, then the handler can returnResult<Json<T>, AppError>.- Middleware on axum via tower — use
tower-httpfor 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:slimor evenscratch. 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.