Tako: A Lightweight Async Web Framework on Tokio and Hyper

by Daniel Boros

Jun 24

16 min read

90 views

Tako (meaning "octopus" in Japanese) is a new lightweight web framework for Rust, built on top of the Tokio runtime and Hyper HTTP library. It is designed to be pragmatic, ergonomic, and extensible, keeping the mental model small while still offering first-class performance and modern conveniences. Tako provides all the core pieces needed to build web services – from routing and request extractors to middleware and plugin support – with an emphasis on simplicity and minimal overhead. In fact, it leverages Hyper and Tokio under the hood for efficient asynchronous IO, meaning it inherits native support for HTTP/2 and TLS and can handle high concurrency with ease.

Some highlights of Tako include:

  • Intuitive Routing – A batteries-included router with straightforward path-based routing, including support for path parameters and optional trailing-slash redirects (TSR).
  • Extractor System – Strongly-typed request extractors for common needs like headers, query parameters, JSON or form bodies, etc., making it easy to pull data out of requests.
  • Streaming & SSE Support – Built-in helpers for streaming responses, including Server-Sent Events (SSE) for real-time updates.
  • Middleware – A simple middleware mechanism that lets you compose synchronous or async middleware functions with minimal boilerplate.
  • Shared Application State – A convenient way to inject and access shared state across handlers (e.g. database connections, config) in a thread-safe manner.
  • Plugin System – An opt-in plugin architecture that allows extending the framework (for things like CORS or rate limiting) without bloating the core API.
  • Tokio + Hyper Powered – Built directly on Hyper (with Tokio), so it achieves minimal overhead and high async performance, and supports features like HTTP/2 and TLS out-of-the-box.

Overall, Tako’s philosophy is to remain explicit and easy to reason about, avoiding “magic” in the API. I was inspired by existing frameworks like Axum but wanted an even more routing-focused and composable approach – keeping things as straightforward as possible. In the sections below, we’ll dive into how Tako works in practice, covering its routing, middleware, request handling, and plugin system, with code examples to illustrate key features.

Routing System

Routing in Tako is centered around the Router struct, which manages a collection of routes and their handlers. Defining routes is straightforward and does not rely on macros – you simply call methods on a Router instance to register routes. For example, here’s how you can set up a couple of routes on a new router:

use tako::router::Router;
use http::Method;

async fn hello_handler(_req: tako::types::Request) -> &'static str {
    "Hello, World!"
}

let mut router = Router::new();
// A simple GET route for the home page
router.route(Method::GET, "/", hello_handler);
// A route with a dynamic path parameter `{id}`
router.route(Method::GET, "/users/{id}", |req| async move {
    // (In a real handler, you'd extract `id` and use it...)
    "User profile page"
});

In this snippet, we create a Router, then add two GET routes. The first is a basic “hello world” route at "/", and the second demonstrates a route with a path parameter. Tako uses a simple convention of placing path parameters in braces (e.g. {id} in the route string). Under the hood, when you add a route, Tako converts the path pattern into a regular expression and stores any parameter names. This allows the router to match incoming request paths and extract the values of dynamic segments at runtime. For instance, a pattern like /users/{id} is turned into a regex that matches /users/123 (or any other value in place of {id}) and captures the id value.

By default, route matching in Tako is exact and case-sensitive, but Tako also provides a convenient option for trailing slash redirection. Using router.route_with_tsr(...) instead of route(...) for a given path will automatically handle requests that either include or omit a trailing /. If a request comes in with the wrong format (say /users/123/ when you registered /users/{id} with TSR), Tako will respond with an HTTP 307 redirect to the canonical path. This feature (often called TSR) helps avoid duplicate route definitions for paths with and without a trailing slash.

When a request comes in, the router’s dispatch logic will iterate through the defined routes to find a match for the request’s path and HTTP method. Once a matching route is found, any path parameters are extracted and stored (internally, they’re put into the request’s extensions for later use by extractors), and the corresponding handler is invoked. If no route matches, Tako returns a simple 404 Not Found response by default.

The routing API keeps things explicit – there’s no “magic” macro expanding your routes, and you can programmatically add or modify routes as needed. This design choice aligns with Tako’s goal of being easy to reason about. Routes are stored in a thread-safe map internally and matched via regex; this approach may not be as blazing fast as a trie-based matcher for huge route tables, but it favors simplicity and has plenty of performance for small to medium services. (As Tako matures, performance of the routing could be improved, but in an early-stage framework correctness and clarity are the priorities.)

Middleware

Tako includes a lightweight middleware system that allows you to wrap additional processing around your request handlers. Middleware in Tako can be either synchronous or asynchronous functions that run before the final route handler (and even have the ability to short-circuit and return a response directly). You can add global middleware that applies to all routes, or route-specific middleware that applies only to a particular route.

To add a global middleware, you use the router.middleware(...) method and provide a closure or function. For example, to log each incoming request, you might do the following:

use anyhow::Result;
use tako::types::Request;

router.middleware(|req: Request, next| async move {
    println!(">> Incoming request: {} {}", req.method(), req.uri().path());
    // call next middleware or handler
    let resp = next.run(req).await;
    println!("<< Responding with status {}", resp.status());
    Ok(resp)  // continue with the response
});

This middleware prints a line before and after processing the request. The closure receives two arguments: the current Request and a Next handler. By calling next.run(req).await, it hands off control to the next middleware in the chain (or ultimately the route handler) and awaits a Response. You can imagine Next as a callback that represents “the rest of the processing chain.” In this example we simply log and pass everything through, but middleware can also modify the request or response, or even abort the chain and return an early response (for instance, an authentication middleware could check credentials and return a 401 Unauthorized without calling next.run). Middleware are executed in the order they are added, and global middleware will always run before any route-specific ones.

Individual routes can have their own middleware as well. The Route struct (which router.route() returns as an Arc<Route>) has a similar .middleware() method for attaching middleware to that specific route. This is useful for cases where you want logic that only runs for one endpoint or a subset of endpoints. For example, you might attach a rate-limiting middleware only to a particularly sensitive route. Under the hood, when dispatching a request, Tako will combine the global middleware list with the route-specific list (preserving their relative order) to build one unified middleware chain for that request.

The middleware API is intentionally minimal and uses plain Rust closures. There’s no need to implement a trait or box up middleware types manually – Tako defines a convenient type alias for middleware as an Arc<dyn Fn(Request, Next) -> Future<Output = Response> + Send + Sync> (this is what BoxMiddleware represents). When you add a middleware via the above methods, Tako internally wraps your closure into this boxed form. This design keeps adding middleware almost as simple as writing an async fn that takes a request and returns either a new Request (to forward along) or a Response. In fact, in the example above we returned Ok(resp), which because of the Responder trait (discussed below) will be converted into a proper HTTP response automatically.

Plugin System

One of Tako’s distinctive features is its plugin system, which allows you to package common middleware or route logic into reusable units. The plugin system is feature-gated (you enable it with the "plugins" feature flag in Cargo), so it’s completely opt-in. If enabled, you can register plugins via router.plugin(...). A plugin in Tako is simply a type that implements the TakoPlugin trait:

use tako::plugins::TakoPlugin;
use tako::router::Router;
use anyhow::Result;

struct MyPlugin;

impl TakoPlugin for MyPlugin {
    fn name(&self) -> &'static str {
        "MyPlugin"
    }
    fn setup(&self, router: &Router) -> Result<()> {
        // attach middleware or routes
        router.middleware(|req, next| async move {
            println!("[MyPlugin] Pre-processing request");
            next.run(req).await  // proceed
        });
        Ok(())
    }
}

The plugin trait requires two things: a name() for identification and a setup() method where the plugin can register whatever it needs on the given router (this could be middleware, new routes, or other initialization). When you call router.plugin(MyPlugin), Tako will store the plugin. Then, just before the server starts listening, it will call each plugin’s setup method to actually attach the functionality to the router. This design ensures that plugins initialize at the right time, and also that they have access to the router to add handlers or middleware as needed.

Tako comes with a couple of built-in plugins (under the tako::plugins module) that demonstrate this system. For example, there’s a CorsPlugin for enabling Cross-Origin Resource Sharing headers automatically, and a RateLimiter plugin for simple IP-based rate limiting. The CORS plugin, in its setup(), simply adds a middleware that inspects incoming requests and adds the appropriate CORS headers to responses (and handles preflight OPTIONS requests). By providing these as pluggable components, Tako keeps the core framework lean – you only pay (in complexity and compile time) for what you need. The plugin system is an extensibility hook; as Tako grows, more community-contributed plugins could emerge for things like sessions, compression, etc., without complicating the router or server code.

Using a plugin is straightforward. After constructing your router, just call the plugin registration method. For example, to enable CORS with default settings:

use tako::plugins::CorsPlugin;
router.plugin(CorsPlugin::default());

Because plugins might carry configuration, you can often construct them with specific options (e.g., allowed origins) before registering. Once registered, the plugin’s effects (like new middleware) apply automatically to incoming requests.

Request Handling and Extractors

Tako does not introduce a new custom Request or Response type from scratch; instead it reuses the robust types from Hyper (via the http crate for some abstractions). A tako::types::Request is actually just a type alias for hyper::Request<hyper::body::Incoming> – essentially an HTTP request with an incoming streaming body. Similarly, tako::types::Response is an alias for hyper::Response<TakoBody>. TakoBody is a thin wrapper around Hyper’s body type, with some utilities to easily create responses from common Rust types (like strings or byte slices). This means if you want to manually inspect headers or the raw URI, you can use all the familiar methods from the http::Request API.

To simplify building responses, Tako defines a Responder trait. Types that implement Responder know how to convert themselves into an HTTP response. Tako provides impls for many basic types: for example, &'static str and String are Responders (producing a 200 OK with the text in the body), () is a Responder (producing an empty 200 OK), and of course Response<TakoBody> itself is a Responder (so you can return a pre-built response directly). This trait is one of the “modern conveniences” that Tako adds on top of Hyper – it lets your handler functions return high-level types (like a string or a JSON value) and have them automatically translated into HTTP responses. For instance, our earlier hello_handler returned &'static str and that was converted to a plain text response behind the scenes. If we wanted to return JSON from a handler, we could simply return a Serde Value or similar, as long as we implement Responder for it (or convert it to a string and let that implementation handle it).

Request extractors are another ergonomic feature Tako provides. These are analogous to frameworks like Axum’s extractors – they pull specific data out of the request (path params, query params, headers, body, etc.) and make it available in a typed manner. Tako’s approach is to define traits FromRequest and FromRequestParts that asynchronous extractor types can implement. The framework includes built-in extractors for common scenarios:

  • Path parameters – for example, if your route is /users/{id}, you can use an extractor to get the id value as a type like u32 easily.
  • Query parameters – parse the URL query string into a struct or map.
  • JSON body – deserialize the request JSON body into a Rust type (using Serde under the hood).
  • Form data or Bytes – read the raw body or form-encoded data.

In the current version of Tako, using these extractors might involve explicitly calling them in your handler. For example, you might do something like:

use tako::extractors::json::Json;
use serde::Deserialize;

#[derive(Deserialize)]
struct MyData { /* fields */ }

async fn create_item(mut req: tako::types::Request) -> tako::types::Response {
    // Use the JSON extractor to parse the body
    let Json(data): Json<MyData> = Json::from_request(&mut req).await.unwrap();
    // Now `data` is a MyData instance populated from the JSON body.
    tako::responder::Responder::into_response("Got it!")  // respond with a message
}

However, the framework is likely to evolve to allow function signature based extractors (where your handler can directly take arguments that implement FromRequest and Tako will inject them). The building blocks are in place in the extractors module, even if the syntax sugar isn’t as slick as in more mature frameworks yet. The design still favors clarity – you can always fall back to manually parsing what you need from the Request if desired, but the extractor helpers save you from a lot of boilerplate for the common cases.

Tokio and Hyper Under the Hood

Because Tako is built on Tokio and Hyper, it inherits a scalable asynchronous foundation. Starting a Tako server is done via the tako::serve function, which takes a TcpListener (from tokio::net) and a Router instance to serve. This is intentionally a simple interface – you control how the listener is created (which means you can bind to whichever address/port, use TLS sockets if needed, etc.) and then hand it off to Tako:

use tokio::net::TcpListener;
use tako::server::serve;

let listener = TcpListener::bind("127.0.0.1:8080").await?;
// ... configure router with routes ...
serve(listener, router).await?;

Underneath, serve is accepting incoming TCP connections and spawning tasks to handle each connection. For each new connection, Hyper’s HTTP/1.1 server is used to handle the parsing of requests and writing of responses. Tako uses Hyper’s service_fn to bridge into the Tako router: essentially, each request received by Hyper is passed into router.dispatch(req).await, and the resulting response is returned to Hyper to send back to the client. This integration is efficient – Hyper is a very fast HTTP engine in Rust, and Tako adds only a thin layer on top for routing and middleware.

Because Hyper (and Tokio) are doing the heavy lifting, Tako can handle a high number of concurrent connections and requests on modest hardware. Async tasks are used for each connection, and inside the request dispatch, the use of async/await means that waiting for IO (e.g., reading the request body or writing the response) doesn’t block other tasks. The framework supports HTTP upgrades as well (Hyper’s server is configured with with_upgrades() to enable things like WebSocket handshakes). In fact, Tako already includes a simple WebSocket module (tako::ws) that can upgrade connections and work with tokio-tungstenite to handle WebSocket messages. This means you can build real-time features on top of Tako, either using SSE (which is one-way server push) or full WebSockets for two-way communication.

It’s worth noting that Hyper’s HTTP/2 support and TLS (with Rustls) are available to Tako users. By using the optional tako::server_tls::serve_tls (and enabling the tls feature), you can provide TLS listener sockets and serve HTTPS. All of this comes largely for free thanks to Hyper’s architecture. Tako’s job is mainly to glue routing and middleware into Hyper’s request/response flow.

Unique Features: Streaming Responses and SSE

Modern web apps often require pushing data to clients or handling streams of events. Tako has first-class support for streaming responses, including Server-Sent Events (SSE). If your handler returns a type that implements Rust’s Stream trait (for example, a stream of bytes or a stream of JSON chunks), you can use Tako’s Sse helper to send it as an SSE response. The tako::sse::Sse struct is essentially a wrapper around a Stream<Item = TakoBytes> (TakoBytes is a tiny wrapper over Bytes for convenience). When you wrap a stream in Sse::new(stream) and return it, Tako’s Responder implementation will set the correct Content-Type: text/event-stream headers and continuously write each item of the stream as a server-sent event frame. This is great for implementing features like live notifications or feeds. For example, you could stream real-time updates from a database or progress of a long computation to the client using SSE, and Tako handles the formatting and headers for you.

For full-duplex communication, Tako’s WebSocket support (via the ws module) lets you upgrade an HTTP connection to a WebSocket and then use tungstenite for messaging. While SSE is simpler (just one-way messages from server to client), WebSockets allow two-way messaging. Tako’s approach is to provide a TakoWs utility to help set up the WebSocket handshake and then you use normal tungstenite streams to read/write messages. This part of the framework is still evolving, but it’s notable that even at this early stage, Tako thought about real-time use cases.

Another nicety is that shared state we mentioned: behind the scenes, Tako uses a global concurrent map for state storage (keyed by a string identifier) which you set via router.state(key, value). This can be used, for instance, to store a database connection pool or configuration that every handler can access. Internally it uses a thread-safe cell so that handlers can retrieve a reference to this state by key. It’s a simple approach, but covers the common need of sharing application-scoped data without resorting to global variables.

Conclusion

Tako is still in its early stages (currently at version 0.2.x), so developers should approach it with appropriate caution. It’s not as battle-hardened or feature-rich as frameworks like Axum, Actix Web, or Rocket yet. There may be rough edges, unoptimized code paths (for example, the routing via regex could be improved), and the API is subject to change as it matures. That said, Tako shows a lot of promise as a lightweight, production-oriented framework. By building on solid foundations (Tokio and Hyper) and focusing on a clear, explicit design, Tako aims to provide a performant web framework that doesn’t hide too much magic from the developer.

If you value simplicity and want fine-grained control over how your server operates, Tako is an intriguing option to watch. It gives you just enough abstraction to be productive – with features like a convenient router, middleware, plugins, and extractors – but it stays out of your way when you need to drop down to low-level details. As it evolves, and with more community feedback, Tako may grow into a truly production-ready framework that remains lean and easy to reason about. For now, it’s a great project to experiment with and contribute to. Just like its namesake octopus, Tako might be small, but it has many flexible arms (routing, middleware, plugins, etc.) that can serve you well in building web services – all while keeping things lightweight and approachable.

References