--- src/lib.rs.before
+++ src/lib.rs.experiment
@@ -2,6 +2,7 @@
 mod completed;
 mod http;
 mod net;
+mod vectorcall;
 use pyo3::exceptions::{PyOSError, PyRuntimeError, PyValueError};
 use pyo3::prelude::*;
 use pyo3::types::{PyBytes, PyDict, PyList};
@@ -591,10 +592,12 @@
         let send = Py::new(
             py,
             Sender {
+                vectorcall: vectorcall::call,
                 state: Rc::downgrade(self),
                 request: r.clone(),
             },
         )?;
+        vectorcall::enable(py, &send);
         let receive = Py::new(
             py,
             Receiver {
@@ -734,8 +737,9 @@
     Ok(())
 }
 
-#[pyclass(unsendable, frozen)]
+#[pyclass(unsendable, frozen, immutable_type)]
 struct Sender {
+    vectorcall: pyo3::ffi::vectorcallfunc,
     state: Weak<State>,
     request: Request,
 }
--- /dev/null
+++ src/vectorcall.rs
@@ -0,0 +1,80 @@
+//! Vectorcall for the existing PyO3 Sender; ownership and thread checks remain
+//! PyO3's. No Rust pointer is kept outside its original Python object.
+use super::Sender;
+use pyo3::{ffi, prelude::*, types::PyTuple};
+
+pub fn enable(py: Python<'_>, sender: &Py<Sender>) {
+    let data = sender.bind(py).borrow();
+    let offset = (&data.vectorcall as *const _ as usize) - sender.as_ptr() as usize;
+    // SAFETY: Sender is frozen, non-subclassable and its type is immutable. The
+    // actual field address determines the offset, without assuming PyO3's layout.
+    // All instances have the same layout and are initialized with call before
+    // exposure. The GIL serializes installation; PyO3 still owns allocation/drop.
+    unsafe {
+        let ty = ffi::Py_TYPE(sender.as_ptr());
+        if (*ty).tp_vectorcall_offset == 0 {
+            (*ty).tp_vectorcall_offset = offset as ffi::Py_ssize_t;
+            (*ty).tp_call = Some(ffi::PyVectorcall_Call);
+            (*ty).tp_flags |= ffi::Py_TPFLAGS_HAVE_VECTORCALL;
+        }
+    }
+}
+
+struct Call;
+impl
+    pyo3::impl_::trampoline::MethodDef<
+        pyo3::impl_::trampoline::fastcall_cfunction_with_keywords::Func,
+    > for Call
+{
+    const METH: pyo3::impl_::trampoline::fastcall_cfunction_with_keywords::Func = invoke;
+}
+
+// SAFETY: CPython invokes this only with a live Sender and a valid vectorcall
+// argument array under the GIL. The PyO3 trampoline attaches its thread guard,
+// catches Rust panics and translates Python errors, as for a generated method.
+pub unsafe extern "C" fn call(
+    sender: *mut ffi::PyObject,
+    args: *const *mut ffi::PyObject,
+    nargs: usize,
+    names: *mut ffi::PyObject,
+) -> *mut ffi::PyObject {
+    unsafe {
+        pyo3::impl_::trampoline::fastcall_cfunction_with_keywords::<Call>(
+            sender,
+            args,
+            ffi::PyVectorcall_NARGS(nargs),
+            names,
+        )
+    }
+}
+
+unsafe fn invoke(
+    py: Python<'_>,
+    sender: *mut ffi::PyObject,
+    args: *const *mut ffi::PyObject,
+    nargs: ffi::Py_ssize_t,
+    names: *mut ffi::PyObject,
+) -> PyResult<*mut ffi::PyObject> {
+    // SAFETY: call and the PyO3 trampoline establish the interpreter/argument
+    // guarantees. Argument count is checked before dereferencing the array.
+    unsafe {
+        let keywords = if names.is_null() {
+            None
+        } else {
+            Some(Bound::from_borrowed_ptr(py, names).cast_into::<PyTuple>()?)
+        };
+        let valid = if let Some(names) = keywords {
+            nargs == 0 && names.len() == 1 && names.get_item(0)?.eq("msg")?
+        } else {
+            nargs == 1
+        };
+        if !valid {
+            return Err(pyo3::exceptions::PyTypeError::new_err(
+                "send() takes one message",
+            ));
+        }
+        let msg = Bound::from_borrowed_ptr(py, *args);
+        let sender = Bound::from_borrowed_ptr(py, sender).cast_into::<Sender>()?;
+        Ok(sender.borrow().__call__(py, msg.cast()?)?.into_ptr())
+    }
+}