A single-threaded-first async HTTP/1.1 server written in Rust.
Moonbeam is designed to be simple, efficient, and free of synchronization overhead by running on a single thread. It leverages the async-io and smol ecosystem to handle concurrent connections asynchronously. By default, it uses a "share-nothing" architecture, avoiding the need for Arc, Mutex, or Send/Sync bounds on your state, though it can easily be extended to multiple threads if desired.
Modern web applications often spend most of their time waiting on I/O (databases, network requests, etc.) rather than performing heavy CPU computation. Moonbeam embraces this by running your application logic on a single thread, utilizing a local executor. This means you can use simple RefCell and Cell primitives for state management, drastically reducing the cognitive overhead and boilerplate often associated with multi-threaded Rust web frameworks.
Before building with Moonbeam, it's essential to understand its execution model:
- No Tokio: Moonbeam is built on
async-ioand thesmolecosystem. It does not usetokiodependencies. This means notokio::spawn, no#[tokio::main], and no tokio-specific database drivers (unless they supportasync-ioorsmol). - Blocking I/O: Because Moonbeam runs handlers on a
LocalExecutoron the main thread, any CPU-heavy computation or blocking I/O (like reading a large file synchronously) will block the entire server.- Solution:
smolsupports async I/O via theblocking::unblockprimitive for offloading heavy tasks to a background thread pool, or you can use theasync_iocrate for native non-blocking operations.
- Solution:
- Single-threaded by default: No
ArcorMutexneeded for shared state. - Multi-threaded support: The
mtfeature spawns worker threads, each with its own state copy. - Simple API: Use the
#[server]macro to turn functions into server handlers. - Routing: The
router!macro provides a clean DSL and efficient implementation for nested groups, middleware, path parameters, and wildcards. - Typed Body Extractors: Use
FromRequestandFromBodytraits for zero-copy, asynchronous body parsing (e.g., JSON). - Static Assets: Built-in
assetshelper for serving files with ETags and MIME type detection. - HTTP/1.1: Persistent connections, chunked transfer encoding, and standard header parsing.
- Zero-cost extractions: Efficient parsing of Cookies, Query Parameters, and Bodies.
- Panic Handling: Optional
catchpanicfeature safely catches panics and returns a 500 error. - Response Compression: On-the-fly
compresssupport (Gzip, Brotli, Zlib). - Graceful Shutdown: Intercepts
signalsfor clean exit. - TLS Support: Secure your server with
rustls(behind thetlsfeature).
Yes. Moonbeam is designed for high performance with minimal overhead. In simple benchmarks using wrk (4 threads, 100 connections, 5 seconds), Moonbeam shows competitive performance for both simple responses and static file serving.
The below benchmarks were performed on a MacBook Pro (M3 Pro). While these simple tests don't represent real-world application complexity, they demonstrate the efficiency of Moonbeam's core request/response loop.
| Framework | Architecture | Requests/sec |
|---|---|---|
| Axum (Tokio) | Multi-threaded | ~216,000 |
| Moonbeam | Multi-Threaded (4 cores) | ~214,000 |
| Moonbeam | Single-Threaded | ~211,000 |
| Node.js | Single-Threaded | ~117,000 |
| Rouille | Thread-per-connection | ~93,000 |
| Framework | Architecture | Requests/sec |
|---|---|---|
| Moonbeam | Multi-Threaded (4 cores) | ~73,000 |
| Moonbeam | Single-Threaded | ~66,000 |
| Axum (Tokio) | Multi-threaded | ~58,000 |
| Rouille | Thread-per-connection | ~56,000 |
| Node.js | Single-Threaded | ~51,000 |
Moonbeam also offers relatively fast compilation times and a small disk footprint. For the simple benchmarks:
| Framework | Clean Build Time | Install Size (Avg) |
|---|---|---|
| Moonbeam | ~4.6s | ~926 KB |
| Axum (Tokio) | ~8.6s | ~1.6 MB |
| Rouille | ~8.9s | ~933 KB |
| Node.js | 0s | ~78 MB |
Note: The Node.js benchmark does not require any build step and relies entirely on built-in standard library modules. However, the Node.js runtime itself typically requires an installation footprint of around 50–100 MB depending on the platform.
Add moonbeam to your Cargo.toml:
[dependencies]
moonbeam = "0.7"Moonbeam is configurable via Cargo features. Most users will want the default features.
default: Enablesmacros,assets,catchpanic,signals, androuter.macros: Enables the#[server]attribute macro to easily createServertrait implementations.assets: Exposes themoonbeam::assetsmodule for serving static files.signals: Hooks into OS signals (SIGINT, SIGTERM) to trigger graceful server shutdown.catchpanic: Wraps your handlers to catch panics gracefully and return500 Internal Server Error.tracing: Instruments the core server loop withtracingspans and events.compress: Enables automatic response compression. (Depends onflate2andbrotli).router: Enables the routing macros (#[route],#[middleware], androuter!).mt: Exposesserve_multito run multiple independent server isolates across available CPU cores.tls: Enables HTTPS support viarustls. Exposesserve_tls,serve_multi_tls, andTlsConfig.
Moonbeam honors the following environment variables:
MOONBEAM_MAX_BODY_SIZE: Maximum size (in Kilobytes) of an incoming HTTP request body. Defaults to1024(1MB). Exceeding this returns a413 Content Too Large.
The simplest way to use Moonbeam.
use moonbeam::{Body, Request, Response, Spawner, server};
#[server(HelloWorld)]
async fn serve(_request: Request, _spawner: Spawner) -> Response {
Response::ok().with_body("Hello, World!", Body::TEXT)
}
fn main() {
println!("Running on 127.0.0.1:8080");
moonbeam::serve("127.0.0.1:8080", || HelloWorld);
}Because the executor runs locally, you can use std::cell::Cell without Mutex.
use std::cell::Cell;
use moonbeam::{Body, Request, Response, Spawner, server};
struct AppState {
count: Cell<u64>,
}
#[server(CounterServer)]
async fn serve(_req: Request, _spawner: Spawner, state: &AppState) -> Response {
let count = state.count.get();
state.count.set(count + 1);
Response::ok().with_body(format!("Request #{}", count), Body::TEXT)
}
fn main() {
let state = AppState { count: Cell::new(0) };
moonbeam::serve("127.0.0.1:8080", move || CounterServer(state));
}If you prefer not to use macros, the same examples above can be easily achieved with only slightly
more boilerplate. Make sure to update Cargo.toml to disable default features:
[dependencies]
moonbeam = { version = "0.7", default-features = false }use std::cell::Cell;
use moonbeam::{Body, Request, Response, Spawner, Server};
struct CounterServer {
count: Cell<u64>,
}
impl Server for CounterServer {
async fn route<'server: 'exec, 'exec>(
&'server self,
_req: Request<'_, '_>,
_spawner: Spawner<'exec>,
) -> Response {
let count = self.count.get();
self.count.set(count + 1);
Response::ok().with_body(format!("Request #{}", count), Body::TEXT)
}
}
fn main() {
moonbeam::serve("127.0.0.1:8080", || CounterServer { count: Cell::new(0) });
}Use the mt feature flag to scale across multiple CPU cores.
use moonbeam::{Request, Response, ThreadCount, Body, Spawner, server, serve_multi};
use std::sync::atomic::{AtomicUsize, Ordering};
struct WorkerState {
thread_id: usize,
}
#[server(Worker)]
async fn serve(_req: Request, _spawner: Spawner, state: &WorkerState) -> Response {
Response::ok().with_body(format!("Hello from thread {}", state.thread_id), Body::TEXT)
}
fn main() {
// Shared setup logic (runs once on the main thread)
let next_id = AtomicUsize::new(0);
serve_multi(
"127.0.0.1:8080",
ThreadCount::Default, // One thread per CPU core
|| {
// This closure runs on each new thread to construct its local state
let id = next_id.fetch_add(1, Ordering::Relaxed);
Worker(WorkerState { thread_id: id })
}
);
}Secure your server using the tls feature.
use moonbeam::{Body, Request, Response, Spawner, server, TlsConfig, serve_tls};
#[server(HelloWorld)]
async fn serve(_request: Request, _spawner: Spawner) -> Response {
Response::ok().with_body("Hello, Secure World!", Body::TEXT)
}
fn main() {
let tls_config = TlsConfig::from_pem("cert.pem", "key.pem")
.expect("Failed to load TLS certificates")
.into_server_config()
.expect("Failed to create server config");
println!("Running HTTPS on 127.0.0.1:4433");
serve_tls("127.0.0.1:4433", tls_config, || HelloWorld);
}The router! macro provides a clean domain-specific language for nesting routes and middleware.
use moonbeam::{Body, Request, Response, Spawner, route, router, serve, middleware};
use moonbeam::router::PathParams;
struct AppState {
api_key: String,
}
// Global Middleware
#[middleware]
async fn logger(req: Request, _spawner: Spawner, _state: &AppState, next: Next) -> Response {
let start = std::time::Instant::now();
let res = next(req).await;
println!("{} {} - {:?}", req.method, req.path, start.elapsed());
res
}
// Scoped Middleware
#[middleware]
async fn require_auth(req: Request, _spawner: Spawner, state: &AppState, next: Next) -> Response {
if req.find_header("X-Api-Key") == Some(state.api_key.as_bytes()) {
next(req).await
} else {
Response::new_with_code(401).with_body("Unauthorized", Body::TEXT)
}
}
// Extractor Handler
#[route]
async fn get_user(PathParams(id): PathParams<&str>) -> Response {
Response::ok().with_body(format!("User ID: {}", id), Body::TEXT)
}
#[route]
async fn not_found() -> Response {
Response::new_with_code(404).with_body("Not Found", Body::TEXT)
}
fn main() {
router!(ApiRouter<AppState> {
with logger
"/api" => {
with require_auth
get("/users/:id") => get_user,
// Unmatched /api/* routes to default 404
_ => !
}
// Custom 404
_ => not_found
});
let state = AppState { api_key: "secret".to_string() };
serve("127.0.0.1:8080", move || ApiRouter::new(state));
}Use the moonbeam-serde crate for flexible, typed body extraction. This supports zero-copy deserialization by borrowing directly from the request buffer.
use moonbeam::{Response, route, router, serve};
use moonbeam_serde::Json;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct User<'a> {
id: u32,
name: &'a str, // Borrowed from the request body
}
#[route]
async fn create_user(Json(user): Json<User<'_>>) -> Json<User<'_>> {
println!("Creating user: {:?}", user);
Json(user)
}
fn main() {
router!(ApiRouter {
post("/users") => create_user
});
serve("127.0.0.1:8080", || ApiRouter);
}Use the moonbeam-forms crate to parse incoming form data, including file uploads.
use moonbeam::{Body, Response, route, router, serve};
use moonbeam_forms::{FormData, Form};
#[route]
async fn handle_form(form: Form<'_>) -> Response {
let mut response_text = String::new();
// Find a specific field (iterator for multiple values)
for data in form.find("username") {
if let FormData::Text(name) = data {
response_text.push_str(&format!("Hello, {}!\n", name));
}
}
// Handle file uploads
for data in form.find("profile_pic") {
if let FormData::File { name, content_type, data } = data {
response_text.push_str(&format!(
"Received file: {} ({}) - {} bytes\n",
name, content_type, data.len()
));
}
}
Response::ok().with_body(response_text, Body::TEXT)
}
fn main() {
router!(App {
post("/submit") => handle_form,
// GET params are also accessible via Form
get("/submit") => handle_form
});
serve("127.0.0.1:8080", || App);
}use moonbeam::{Request, Response, Spawner, server, assets::get_asset};
#[server(StaticServer)]
async fn serve(req: Request, _spawner: Spawner) -> Response {
let etag = req.find_header("If-None-Match");
get_asset(req.path, etag, "./public").await
}
fn main() {
moonbeam::serve("127.0.0.1:8080", || StaticServer);
}This project is licensed under the MIT License.