Skip to content
Go back

Axum Backend Series - Introduction

Edit page

In this series, we are going to learn backend engineering using Axum, a web framework from Tokio team. We will build a single project that’ll be a Medium API clone following the RealWorld API Spec —> https://github.com/gothinkster/realworld

This is our project’s Github Repo Link —> https://github.com/0xshadow-dev/realworld-axum-api

By the end of this series you’d have built a Rest API backend using Axum and Rust with Authentication, testing, observability, CI/CD etc.

This series is completely hands-on series with each lesson having lots of code and we will implement something in each lesson.

Prerequisites

This is going to be a long series, so let’s get started.

In this post, we are going to setup our Axum backend project and create a simple /health endpoint. This will help us get a basic familiarity with Axum. So, let’s start.

Setting up the Project

Open up your terminal and in your preferred directory and let’s create a new cargo project.

cargo new realworld-axum-api
cd realworld-axum-api

This create a basic Rust project with a simple Hello World program in the src/main.rs file.

If you’ll open the main.rs file, you’ll see the basic Hello World program.

fn main() {
    println!("Hello, world!");
}

For this post, we are going to modify this main.rs file and convert this from a “Hello World!” program to a web server.

Add Dependencies

Ok, now we need to add dependencies to build our basic server. We might add more dependencies in the future but for now the following 3 are enough.

[package]
name = "realworld-axum-api"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.8"
tokio = { version = "1.0", features = ["full"] }
serde_json = "1.0"

I’ll explain what we added and why but this is how our Cargo.toml file would look like. So, either copy paste the entire thing or just add those dependencies in your Cargo.toml file.

Ok, so now we can start working on building our first basic web server in rust as we are done with adding all the dependencies we need.

Creating Our First Web Server in Axum

Let me first give you the entire code here, then I’ll explain everything in detail. From next post onwards I’ll give you the github repo from where you can get the source code as we will start working with multiple files and slowly the lines of code will become a lot.

use axum::{response::Json, routing::get, Router};
use serde_json::{json, Value};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/health", get(health_check));

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

async fn health_check() -> Json<Value> {
    Json(json!({
        "status": "ok",
        "message": "Server is running"
    }))
}

Ok, let’s now understand everything

Imports

use axum::{response::Json, routing::get, Router};
use serde_json::{json, Value};

The Main Function Signature

We need to understand this part clearly to make sense of tokio, async, concurrency and parallelism.

#[tokio::main]
async fn main() {

What is Tokio and Why Do We Need It?

Tokio is an async runtime that allows us to do async operations in Rust. But what does that actually mean?

To understand that, let’s understand what synchronous programming does.

Synchronous Programming

Here, our program executes one instruction at a time. Let’s understand this with a simple pseudocode.

// Synchronous pseudocode
fn handle_request() {
    let data = read_from_database();     // Takes 50ms, program waits
    let result = process_data(data);     // Takes 10ms, program waits
    send_response(result);               // Takes 5ms, program waits
}
// Total time: 65ms per request

Here, it first reads from the database, then processes the data and finally sends a response. All this takes around 65ms. Note that this is only for 1 request.

The problem is if you get 1000 requests, they are handled one after another:

This is terrible for web servers.

Asynchronous Programming

With async programming, when you hit a “waiting” operation (like database calls, file I/O, network requests), our program can pause that function and work on something else:

// Async version
async fn handle_request() {
    let data = read_from_database().await;    // Pauses here, lets other work happen
    let result = process_data(data);          // CPU work, no pausing needed
    send_response(result).await;              // Pauses here, lets other work happen
}

While one request is waiting for the database, we can start processing other requests:

Time: 0ms
├─ Request 1: starts database call (will take 50ms)
├─ Request 2: starts database call (will take 50ms)
├─ Request 3: starts database call (will take 50ms)

Time: 50ms
├─ Request 1: database done, continues processing
├─ Request 2: database done, continues processing
├─ Request 3: database done, continues processing

All 1000 requests could potentially complete in just slightly over 65ms instead of 65 seconds!

What Makes Operations “Waiting”?

Waiting operations are anything that involves going outside our CPU to get data:

But why they’re “waiting” operations?

Why they’re “waiting”?

Our CPU can only work with data in RAM. When data is anywhere else (disk, network, database), our CPU physically waits for it to be fetched. During this waiting time, async lets our CPU work on other requests instead of sitting idle.

In our current server we don’t do waiting operations in our handler, but the server framework does (waiting for new connections, reading request data, sending responses).

Note: Concurrency means dealing with multiple tasks at once (like juggling), while Parallelism means actually executing tasks simultaneously (like multiple people working). Tokio provides both - concurrency within threads and parallelism across CPU cores. Web servers primarily need concurrency because most time is spent waiting for I/O, not doing CPU-intensive work.

Why Tokio Macro is Necessary

Rust’s standard main function is synchronous by default:

fn main() {
    // This is a synchronous context
    // You cannot use .await here
    // You cannot call async functions directly
}

But we need async capabilities for our web server. The Tokio macro enables it to be async.

Creating the Router

let app = Router::new()
    .route("/health", get(health_check));

We are creating a routing table, that’ll map our URLs to functions like /health to health_check function.

So, Router::new() creates an empty router with no routes defined. Then we use .route("/health", get(health_check)) method to add a /health route that can only support GET method and when a user will request the /health endpoint with a GET method, our router to call the health_check function and returns whatever the function returns.

If you’ll change the get(health_check) to post(health_check) and restart the server and request http://localhost:3000/health in your browser. You’ll see it’ll fail. To check the reason, open the Network tab and you’ll the server will return with a status of Method Not Allowed. method-not-allowed.png

Network Binding

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
    .await
    .unwrap();

Here, we are creating a listener that’ll actually receive HTTP requests on port 3000. We are using the port 3000 as this is a common development port.

Starting the Server

println!("Server running on http://0.0.0.0:3000");
axum::serve(listener, app).await.unwrap();

First, we are just printing a message that’ll say that the server is running. Then it actually starts the server loop.

The axum::serve(listener, app) uses listener to get the actual socket where it receives the HTTP requests and it also uses app as that’s our router who knows how to handle those requests like /health should execute the health_check function.

The Handler Function

async fn health_check() -> Json<Value> {
    Json(json!({
        "status": "ok",
        "message": "Server is running"
    }))
}

This function defines what will happen when our router will call health_check function for the endpoint /health. We call these kind of functions as handlers and we are making this function an async function so that it can be paused or resumed, allowing other request to be processed while this one runs.

Testing Our Basic Server

Let’s start our server using the following command:

cargo run

You should see

Server running on http://0.0.0.0:3000

Now, open another terminal and run:

curl http://localhost:3000/health

You should see a JSON response:

{"message":"Server is running","status":"ok"}

Now, let’s understand what happened when we made that request. When we run curl http://localhost:3000/health, here’s what happens:

  1. The TCP listener receives the HTTP request
  2. Axum parses it into method=GET, path=“/health”
  3. Router looks up “/health” + GET and finds health_check
  4. health_check() function runs
  5. Json(...) wrapper creates proper HTTP response
  6. Response is sent back through the TCP connection

Conclusion

Congratulations on building your first Axum web server. In this post, we understood a lot of things like setting up a basic Axum project, fundamentals of async programming and how its important for web servers, how to create routes and handlers and finally the complete request-response flow.

In the next post, we are going to add PostgreSQL database, we will learn and use Docker to set that up, we will create a proper project structure with multiple modules and create a database health check. See you soon.

This post is part of the "Backend Engineering in Axum" series. View all posts in this series


Edit page

Subscribe to Newsletter

Share this post on:

Next Post
Building a Brainfuck Interprer in Rust