Home Posts Rust Pin & Async Traits [2026] Driver Reference Guide
Developer Reference

Rust Pin & Async Traits [2026] Driver Reference Guide

Rust Pin & Async Traits [2026] Driver Reference Guide
Dillip Chowdary
Dillip Chowdary
Tech Entrepreneur & Innovator · May 02, 2026 · 11 min read

Bottom Line

For high-performance drivers, keep the hot path on native async traits plus explicit pin projection. Boxed futures still matter, but mainly when you need dyn dispatch or legacy compatibility.

Key Takeaways

  • Use native async traits on Rust 1.75+ for static dispatch hot paths.
  • Reach for pin-project when a pinned driver struct owns pinned and unpinned fields.
  • Use trait-variant when public async traits need explicit Send futures.
  • Use async-trait only when dyn dispatch or older ecosystem constraints matter.

Rust’s pinning model is still the boundary between elegant async code and subtle UB-shaped mistakes, especially in driver code that polls state machines, DMA-like buffers, or transport futures by hand. In Rust 1.75, native async fn in traits became stable, which removes a lot of boilerplate, but it does not remove the need to understand Pin, structural projection, Send bounds, and dyn-compatibility tradeoffs.

  • Rust 1.75 stabilized async fn in traits via return-position impl Future.
  • async fn traits are still not dyn-compatible, so generic fast paths and boxed-object paths remain separate decisions.
  • pin-project 1.1.11 is the ergonomic choice when a driver struct mixes pinned and unpinned fields.
  • pin-project-lite 0.2.17 exists mainly to avoid proc-macro dependencies, not to win runtime performance.

Quick Map

Bottom Line

Use native async fn traits for static dispatch, keep pinned projections explicit, and only pay for boxed futures when you actually need dyn dispatch or compatibility glue.

What Changed

  • Rust 1.75 made trait methods returning hidden futures stable through native async fn in traits.
  • The Rust team still recommends care with public traits because callers cannot add future bounds later.
  • For public async traits that must support multithreaded executors, trait-variant 0.1.2 is the official ergonomic bridge.
  • For dyn dispatch, async-trait 0.1.89 remains the practical boxed-future fallback.

Choose The Right Tool

Need Preferred Tool Why
Pin a local future pin! No heap allocation and a stable pinned reference for polling.
Store a future long-term Box::pin Own the future and give it a stable address.
Project pinned fields safely pin-project Generates safe projection methods for structs and enums.
Avoid proc-macro dependencies pin-project-lite Same safety story, smaller surface, fewer features.
Public async trait with Send trait_variant::make Generates a Send-aware variant without boxing.
Dyn-compatible async trait #[async_trait] Erases the future into Pin<Box<dyn Future...>>.

Live Search And Shortcuts

Markup For A Live Filter

<div class='not-prose mb-6'>
  <label for='cheat-filter' class='sr-only'>Filter the cheat sheet</label>
  <input
    id='cheat-filter'
    type='search'
    placeholder='Filter by pin, trait, send, drop...'
    class='w-full rounded-xl border px-4 py-3'
  />
</div>

<div id='cheat-grid'>
  <section data-filter-item data-tags='pin stack local future'>...</section>
  <section data-filter-item data-tags='trait send public async'>...</section>
  <section data-filter-item data-tags='drop projection destructor'>...</section>
</div>

Minimal Filter Script

const input = document.querySelector('#cheat-filter');
const items = [...document.querySelectorAll('[data-filter-item]')];

function applyFilter() {
  const q = input.value.trim().toLowerCase();
  for (const item of items) {
    const haystack = (item.textContent + ' ' + (item.dataset.tags || '')).toLowerCase();
    item.hidden = q && !haystack.includes(q);
  }
}

document.addEventListener('keydown', (event) => {
  if (event.key === '/' && document.activeElement !== input) {
    event.preventDefault();
    input.focus();
  }

  if (event.key === 'Escape' && document.activeElement === input) {
    input.value = '';
    applyFilter();
    input.blur();
  }
});

input.addEventListener('input', applyFilter);

Suggested Keyboard Shortcuts

Key Action Use
/ Focus filter Jump straight to the live search box.
Esc Clear filter Reset the filtered cheat sheet quickly.
j Next section Useful if you wire up sticky ToC navigation.
k Previous section Pairs well with long reference pages.
c Copy active code block Good complement to automatic copy buttons.

Commands And Snippets By Purpose

Pin A Future Locally

  • Use pin! when the future only needs a stable address in the current scope.
  • This avoids heap allocation and fits tight driver loops well.
  • pin! has been available since Rust 1.68.0.
use std::future::Future;
use std::pin::pin;

async fn run_driver() {}

fn poll_once(cx: &mut std::task::Context<'_>) {
    let fut = pin!(run_driver());
    let _ = Future::poll(fut.as_ref(), cx);
}

Heap-Pin A Future You Need To Own

  • Use Box::pin when the future must outlive the current stack frame.
  • This is the common bridge for trait objects, registries, and boxed task queues.
use std::future::Future;
use std::pin::Pin;

async fn run_driver() {}

let fut: Pin<Box<dyn Future<Output = ()>>> = Box::pin(run_driver());

Project Pinned Fields Safely

  • #[pin_project] generates project() and project_ref().
  • Pinned fields stay pinned; unpinned fields become ordinary references.
  • This is the standard pattern for polling a field future inside a driver struct.
use std::future::Future;
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use pin_project::pin_project;

#[pin_project]
struct DriverInit<F> {
    #[pin]
    init: F,
    retries: u8,
}

impl<F> Future for DriverInit<F>
where
    F: Future<Output = io::Result<()>>,
{
    type Output = io::Result<()>;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let this = self.project();
        this.init.poll(cx)
    }
}

Define A Public Async Trait Without Boxing

  • Use trait_variant::make when callers may need a Send future.
  • This matches the Rust team’s current recommendation for public async traits.
#[trait_variant::make(DeviceIo: Send)]
pub trait LocalDeviceIo {
    async fn read_frame(&mut self, buf: &mut [u8]) -> usize;
    async fn flush(&mut self);
}

Fall Back To Dyn Dispatch

  • Native async traits are not dyn-compatible.
  • #[async_trait] remains the direct answer when you need Box<dyn Trait>.
  • The tradeoff is boxed futures and type erasure on each async method.
use async_trait::async_trait;

#[async_trait]
trait DeviceIo {
    async fn read_frame(&mut self, buf: &mut [u8]) -> usize;
}

fn register(dev: Box<dyn DeviceIo + Send>) {
    let _ = dev;
}

Configuration

Cargo.toml Presets

[dependencies]
pin-project = "1"
pin-project-lite = "0.2"
trait-variant = "0.1.2"
async-trait = "0.1.89"
  • Use pin-project 1.1.11 when you want the richer macro surface, enum support, #[pinned_drop], and better diagnostics.
  • Use pin-project-lite 0.2.17 when your main goal is avoiding proc-macro dependencies.
  • Set your practical floor to Rust 1.75 if you want native async traits in library code.
  • Keep async-trait isolated to object-safe seams so boxed futures do not spread across hot paths.

If you want to normalize the snippets before shipping internal docs or runbooks, the TechBytes Code Formatter is a clean last pass.

Advanced Usage

Manual Poll Driver Pattern

  • Polling a field future directly avoids extra wrapper allocations.
  • Projection keeps the field borrow precise, which matters once your driver grows multiple states.
  • This is where Pin stops being theory and becomes a mechanical requirement.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use pin_project::pin_project;

#[pin_project]
struct ConnectThenRun<C, R> {
    #[pin]
    connect: C,
    #[pin]
    run: Option<R>,
}

impl<C, R> Future for ConnectThenRun<C, R>
where
    C: Future<Output = R>,
    R: Future<Output = ()>,
{
    type Output = ();

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        let mut this = self.project();

        if let Some(run) = this.run.as_mut().as_pin_mut() {
            return run.poll(cx);
        }

        let next = std::task::ready!(this.connect.as_mut().poll(cx));
        this.run.set(Some(next));

        if let Some(run) = this.run.as_mut().as_pin_mut() {
            run.poll(cx)
        } else {
            Poll::Pending
        }
    }
}

Custom Drop Without Moving Pinned Fields

  • #[pinned_drop] lets teardown logic see Pin<&mut Self>.
  • That keeps destructors honest when pinned fields must never be moved during cleanup.
use std::pin::Pin;
use pin_project::{pin_project, pinned_drop};

#[pin_project(PinnedDrop)]
struct Tx<F> {
    #[pin]
    flush: F,
    closed: bool,
}

#[pinned_drop]
impl<F> PinnedDrop for Tx<F> {
    fn drop(self: Pin<&mut Self>) {
        let this = self.project();
        *this.closed = true;
    }
}
Watch out: Native async fn in traits and methods returning impl Trait are not dyn-compatible. If your API must accept Box<dyn Trait>, plan that seam explicitly instead of discovering it late.
Pro tip: pin-project-lite is mostly a dependency-graph choice, not a runtime optimization. Its own docs note there is almost no difference in generated code compared with pin-project.

Review Checklist

  • Do all structurally pinned fields carry #[pin]?
  • Does every public async trait make an explicit Send decision?
  • Are boxed futures confined to dyn-dispatch boundaries?
  • Does drop logic avoid taking &mut Self on pinned types?
  • Are local one-shot futures using pin! instead of unnecessary boxing?

FAQs

When do I actually need Pin in an async driver?

You need Pin when the polled future or state machine must not move after polling begins, or when a safe API promises that certain fields stay at a stable address. That shows up quickly in manual poll implementations and any self-referential or projection-heavy driver type.

Should I replace #[async_trait] everywhere now?

No. Replace it on static-dispatch hot paths first, because native async traits remove boxing there. Keep #[async_trait] where dyn dispatch is part of the API surface or migration cost is higher than the runtime win.

What is the real difference between pin-project and pin-project-lite?

pin-project is the feature-rich ergonomic choice. pin-project-lite exists mainly to avoid proc-macro dependencies, and its docs explicitly say there is almost no difference in generated code.

Why does my async trait fail when I try Box<dyn Trait>?

Because native async trait methods hide a future type, which makes the trait not dyn-compatible under today’s Rust Reference rules. Use #[async_trait] or redesign the object-safe boundary.

Frequently Asked Questions

When do I need Pin in an async driver? +
Use Pin when a polled future or driver state machine must not move after polling begins, or when your API promises stable field addresses. In practice, that usually appears in manual poll implementations, self-referential layouts, and structs that project pinned fields into sub-futures.
Should I still use #[async_trait] after Rust 1.75? +
Yes, but only where it solves a real boundary problem. Native async fn traits are better for static-dispatch hot paths, while #[async_trait] still matters when you need Box<dyn Trait>, boxed futures, or broader compatibility with existing object-oriented designs.
What is the difference between pin-project and pin-project-lite? +
pin-project is the richer option, with support for features like #[pinned_drop], custom UnsafeUnpin, and better diagnostics. pin-project-lite is a lighter declarative-macro option used mainly to avoid proc-macro dependencies, not to gain runtime speed.
Why is my async trait not dyn compatible? +
Under the Rust Reference, methods returning opaque types are not dyn-compatible, and native async fn methods desugar to hidden future return types. If you need trait objects, switch that seam to #[async_trait] or split the API so static and dynamic dispatch stay separate.

Get Engineering Deep-Dives in Your Inbox

Weekly breakdowns of architecture, security, and developer tooling — no fluff.

Found this useful? Share it.