
Getting Started with Rust: Building a Safe, Concurrent Counter Service
What you will build
The service will support three operations:
GET /countreturns the current counter valuePOST /incrementincreases the counter by onePOST /resetsets 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_serviceAdd 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?
| Crate | Purpose |
|---|---|
axum | HTTP routing and request handling |
tokio | Async runtime for serving requests |
serde | JSON 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
Arcinto each handler - lock the mutex when reading or updating the value
Why Arc<Mutex<T>>?
Arcgives shared ownership across tasksMutexensures only one task mutates the value at a timei64is 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_CONTENTThis 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 runThen test it with curl:
curl http://127.0.0.1:3000/countExpected output:
{"value":0}Increment the counter:
curl -X POST http://127.0.0.1:3000/incrementExpected output:
{"value":1}Check the value again:
curl http://127.0.0.1:3000/countReset it:
curl -X POST -i http://127.0.0.1:3000/resetYou 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.
| Situation | Recommended approach |
|---|---|
| Small shared counter | Arc<Mutex<T>> |
| High-frequency numeric updates | Atomic types such as AtomicU64 |
| Large read-heavy state | Arc<RwLock<T>> |
| Complex state transitions | Dedicated 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.
