What you will build

The service will support three operations:

  • GET /count returns the current counter value
  • POST /increment increases the counter by one
  • POST /reset sets the counter back to zero

This is a good starter project because it introduces several core Rust concepts in a realistic context:

  • shared mutable state
  • thread-safe synchronization
  • request handling
  • JSON responses
  • basic project structure

You will also see how to keep the implementation small and readable.


Why a counter service is a useful first service

A counter may seem trivial, but it is a strong learning example because it forces you to solve a real concurrency problem: multiple requests may arrive at the same time, and the service must update shared state correctly.

In many languages, this kind of code can become fragile quickly. In Rust, the compiler helps you choose safe concurrency primitives up front. That makes the example ideal for learning how to structure stateful services without relying on hidden global variables or unsafe code.


Project setup

Create a new binary project:

cargo new counter_service
cd counter_service

Add dependencies to Cargo.toml:

[package]
name = "counter_service"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }

Why these crates?

CratePurpose
axumHTTP routing and request handling
tokioAsync runtime for serving requests
serdeJSON serialization and deserialization

This stack is small, modern, and widely used in Rust web development.


Designing the shared state

The counter must be accessible from multiple request handlers. Since handlers may run concurrently, the state needs to be synchronized.

A common pattern is:

  • store the counter in Arc<Mutex<i64>>
  • clone the Arc into each handler
  • lock the mutex when reading or updating the value

Why Arc<Mutex<T>>?

  • Arc gives shared ownership across tasks
  • Mutex ensures only one task mutates the value at a time
  • i64 is a simple numeric type for the counter

This pattern is simple and appropriate for a small service. For higher throughput, you might later switch to atomics, but Mutex is easier to understand and extend.


Implementing the service

Replace src/main.rs with the following code:

use axum::{
    extract::State,
    http::StatusCode,
    routing::{get, post},
    Json, Router,
};
use serde::Serialize;
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;

#[derive(Clone)]
struct AppState {
    counter: Arc<Mutex<i64>>,
}

#[derive(Serialize)]
struct CounterResponse {
    value: i64,
}

#[tokio::main]
async fn main() {
    let state = AppState {
        counter: Arc::new(Mutex::new(0)),
    };

    let app = Router::new()
        .route("/count", get(get_count))
        .route("/increment", post(increment))
        .route("/reset", post(reset))
        .with_state(state);

    let listener = TcpListener::bind("127.0.0.1:3000")
        .await
        .expect("failed to bind address");

    println!("Listening on http://127.0.0.1:3000");

    axum::serve(listener, app)
        .await
        .expect("server error");
}

async fn get_count(State(state): State<AppState>) -> Json<CounterResponse> {
    let value = *state.counter.lock().expect("counter mutex poisoned");
    Json(CounterResponse { value })
}

async fn increment(State(state): State<AppState>) -> Json<CounterResponse> {
    let mut counter = state.counter.lock().expect("counter mutex poisoned");
    *counter += 1;
    Json(CounterResponse { value: *counter })
}

async fn reset(State(state): State<AppState>) -> StatusCode {
    let mut counter = state.counter.lock().expect("counter mutex poisoned");
    *counter = 0;
    StatusCode::NO_CONTENT
}

Understanding the code

AppState

AppState holds the shared counter. It derives Clone, which is important because Axum may clone the state as it routes requests.

#[derive(Clone)]
struct AppState {
    counter: Arc<Mutex<i64>>,
}

The Arc is cloned cheaply; it does not duplicate the counter itself. Instead, it creates another reference to the same shared value.

CounterResponse

This struct defines the JSON returned by the GET and POST /increment endpoints.

#[derive(Serialize)]
struct CounterResponse {
    value: i64,
}

Using a typed response is better than manually building JSON strings. It reduces formatting mistakes and keeps the API consistent.

get_count

This handler reads the current value:

let value = *state.counter.lock().expect("counter mutex poisoned");

The lock gives access to the counter. The dereference copies the i64 out of the mutex guard, so the lock can be released immediately after the value is read.

increment

This handler mutates the counter:

let mut counter = state.counter.lock().expect("counter mutex poisoned");
*counter += 1;

The mutable guard ensures only one request can update the value at a time. That prevents race conditions such as two requests reading the same value and writing back the same incremented result.

reset

This handler clears the counter and returns 204 No Content:

StatusCode::NO_CONTENT

This is a good REST-style response when the operation succeeds but there is no response body to return.


Running and testing the service

Start the server:

cargo run

Then test it with curl:

curl http://127.0.0.1:3000/count

Expected output:

{"value":0}

Increment the counter:

curl -X POST http://127.0.0.1:3000/increment

Expected output:

{"value":1}

Check the value again:

curl http://127.0.0.1:3000/count

Reset it:

curl -X POST -i http://127.0.0.1:3000/reset

You should see a 204 No Content response.


Best practices for this pattern

Keep lock scope small

Hold the mutex only as long as necessary. In this example, the lock is acquired, the counter is updated or read, and the guard is dropped immediately afterward.

This matters because long lock durations can reduce throughput and increase latency under load.

Avoid doing I/O while holding a lock

Do not perform network calls, file operations, or expensive computation while the mutex is locked. If a handler needs to do more work, copy the data out first, release the lock, and continue processing.

Use typed responses

Returning structured JSON through serde makes your API easier to evolve. If you later add metadata such as timestamps or request IDs, the response type can grow naturally.

Prefer explicit state over globals

Placing state in AppState makes dependencies visible. That improves testability and makes the service easier to reason about than a hidden global variable.


When to use Mutex and when not to

Mutex is a good default for simple shared state, but it is not always the best choice.

SituationRecommended approach
Small shared counterArc<Mutex<T>>
High-frequency numeric updatesAtomic types such as AtomicU64
Large read-heavy stateArc<RwLock<T>>
Complex state transitionsDedicated actor/task or message passing

For this tutorial, Mutex is ideal because the code is easy to understand and the performance is more than sufficient for a starter service.


Extending the service safely

Once the basic counter works, you can extend it in several practical directions.

Add a /status endpoint

You might return both the counter value and a health indicator:

#[derive(Serialize)]
struct StatusResponse {
    value: i64,
    healthy: bool,
}

This is useful when integrating with monitoring systems or load balancers.

Add request validation

If you later introduce endpoints that accept input, validate it before locking shared state. That keeps invalid requests from consuming synchronization resources.

Persist the counter

The current implementation stores the value in memory, so it resets when the process exits. To persist it, you could:

  • write the value to a file after each update
  • store it in SQLite
  • send updates to an external database

If you add persistence, be careful to separate state mutation from storage I/O so the service remains responsive.


A note on error handling

The example uses expect(...) for simplicity, but production code should handle errors more gracefully.

For example, a poisoned mutex indicates that another thread panicked while holding the lock. In a real service, you might map that to a structured error response instead of crashing the process.

A more robust version could return Result<_, StatusCode> from handlers and convert failures into 500 Internal Server Error responses. That approach scales better as the service grows.


What you learned

This tutorial showed how to build a small Rust web service with shared mutable state. Along the way, you learned how to:

  • define application state with Arc<Mutex<T>>
  • expose HTTP routes with Axum
  • serialize JSON responses with Serde
  • handle concurrent updates safely
  • structure a minimal service for future growth

The same design pattern appears in many real services: a small amount of shared state, a clear API, and careful synchronization. Rust makes that pattern explicit and safe.

Learn more with useful resources