Async en traits lleva estabilizado desde Rust 1.75, allá por finales de 2023. Han pasado más de dos años y sigue siendo una de las zonas del lenguaje donde más gente tropieza, porque «estable» no significaba «completo». En 2026, con Rust 1.85 y la edición 2024 ya asentadas, este es el estado real: qué funciona sin trucos y dónde todavía hace falta el crate async-trait.
Lo que sí funciona de serie: dispatch estático
Declarar un método async fn directamente en un trait ya no requiere ningún crate externo, siempre que el trait se use con dispatch estático (genéricos, no dyn Trait):
trait Repositorio {
async fn buscar(&self, id: u32) -> Option<String>;
}
struct RepositorioMemoria;
impl Repositorio for RepositorioMemoria {
async fn buscar(&self, id: u32) -> Option<String> {
if id == 1 {
Some("encontrado".to_string())
} else {
None
}
}
}
async fn usar_repositorio<R: Repositorio>(repo: &R, id: u32) {
if let Some(valor) = repo.buscar(id).await {
println!("{valor}");
}
}
Esto compila y funciona igual de bien que un método síncrono normal, sin macros, sin dependencias, sin overhead de asignación en el heap. Para la inmensa mayoría de código de aplicación, donde el tipo concreto se conoce en tiempo de compilación, esto es ya suficiente.
Async closures, la otra pieza que llegó con la edición 2024
Junto con async fn en traits, la edición 2024 trajo closures async || {}, que devuelven un future en vez de ejecutarse inmediatamente:
let procesar = async |valor: i32| -> i32 {
valor * 2
};
let resultado = procesar(21).await;
println!("{resultado}");
Antes de esto, para lograr algo parecido había que envolver el cuerpo en un bloque async move { ... } dentro de una closure normal, con toda la fricción de captura de variables que eso implicaba.
Donde se sigue rompiendo: dyn Trait
Aquí está el límite real. Un trait con métodos async fn no se puede convertir en dyn Trait directamente, porque el tamaño del future que devuelve cada implementación es distinto, y dyn Trait necesita un tamaño conocido en tiempo de compilación:
// Esto NO compila:
fn obtener_repositorio() -> Box<dyn Repositorio> {
Box::new(RepositorioMemoria)
}
El compilador se queja de que el trait no es «object safe» por culpa del método async. Y este es exactamente el caso de uso donde dyn Trait más se necesita: inyección de dependencias, plugins, cualquier sitio donde el tipo concreto se decide en tiempo de ejecución.
La solución práctica: async-trait sigue vivo
Para ese escenario, el crate async-trait sigue siendo la vía recomendada. Convierte el async fn en un método que devuelve Pin<Box<dyn Future>> por debajo, a costa de una asignación en el heap por llamada:
use async_trait::async_trait;
#[async_trait]
trait Repositorio {
async fn buscar(&self, id: u32) -> Option<String>;
}
#[async_trait]
impl Repositorio for RepositorioMemoria {
async fn buscar(&self, id: u32) -> Option<String> {
if id == 1 { Some("encontrado".to_string()) } else { None }
}
}
fn obtener_repositorio() -> Box<dyn Repositorio> {
Box::new(RepositorioMemoria)
}
El coste de esa asignación extra es, en la práctica, irrelevante para la mayoría de aplicaciones (una llamada de red o a base de datos ya cuesta órdenes de magnitud más que un Box::new), así que no hay que descartar el crate por miedo al rendimiento salvo en código realmente sensible a la latencia en el rango de nanosegundos.
Bounds Send y Sync: la letra pequeña
Incluso con dispatch estático, hay una trampa habitual: por defecto, el future que devuelve un async fn en un trait no lleva bound Send, lo que rompe en tiempo de compilación si ese future se mueve entre hilos, por ejemplo al usarlo con tokio::spawn. Hay que pedirlo explícitamente con la sintaxis de trait bounds sobre el propio future, algo que sigue siendo más verboso de lo que a la mayoría le gustaría.
Guía rápida para 2026
Con genéricos y dispatch estático, en la inmensa mayoría de código de aplicación: async fn nativo, sin crates. Con dyn Trait, en plugins e inyección de dependencias: async-trait sigue siendo la herramienta correcta, no un parche temporal a evitar. Y si el código necesita moverse entre hilos con tokio::spawn u otro runtime multi-hilo, hay que revisar los bounds Send desde el principio, no cuando el compilador ya se ha quejado tres veces.
Relacionado: 12 funciones y patrones de Rust que casi nadie usa.
