--- src/lib.rs.before
+++ src/lib.rs.experiment
@@ -184,8 +184,7 @@
     prototype: Py<PyDict>,
     asgi: Py<PyDict>,
     done: Py<PyAny>,
-    task_type: Py<PyAny>,
-    task_options: Py<PyDict>,
+    spawn: Py<PyAny>,
     connections: RefCell<HashMap<u64, Link>>,
     tasks: RefCell<HashMap<u64, Py<PyAny>>>,
     next: Cell<u64>,
@@ -606,22 +605,9 @@
             let coro = self.app.bind(py).call1((scope, receive, send))?;
             // Task's eager mode runs until the first real wait. Preserve embedding
             // applications' custom task factories by using their normal loop path.
-            let task = if self
-                .loop_
-                .bind(py)
-                .call_method0(pyo3::intern!(py, "get_task_factory"))?
-                .is_none()
-                && self.eager_left.get() > 0
-            {
-                self.eager_left.set(self.eager_left.get() - 1);
-                self.task_type
-                    .bind(py)
-                    .call((coro,), Some(self.task_options.bind(py)))?
-            } else {
-                self.loop_
-                    .bind(py)
-                    .call_method1(pyo3::intern!(py, "create_task"), (coro,))?
-            };
+            let eager = self.eager_left.get() > 0;
+            self.eager_left.set(self.eager_left.get().saturating_sub(1));
+            let task = self.spawn.bind(py).call1((coro, eager))?;
             let done = Done {
                 state: Rc::downgrade(self),
                 request: r.clone(),
@@ -1237,10 +1223,11 @@
         let poller = net::Poller::new()?;
         poller.watch(listener.as_raw_fd(), 0, libc::EPOLLIN as u32, true)?;
         let done = completed::new(py)?;
-        let task_type = py.import("asyncio")?.getattr("Task")?.unbind();
-        let task_options = PyDict::new(py);
-        task_options.set_item("loop", loop_.bind(py))?;
-        task_options.set_item("eager_start", true)?;
+        let spawn = py
+            .import("skirr")?
+            .getattr("_task_runner")?
+            .call1((loop_.bind(py),))?
+            .unbind();
         Ok(Self {
             state: Rc::new(State {
                 listener,
@@ -1251,8 +1238,7 @@
                 prototype: prototype.unbind(),
                 asgi: asgi.unbind(),
                 done,
-                task_type,
-                task_options: task_options.unbind(),
+                spawn,
                 connections: RefCell::new(HashMap::new()),
                 tasks: RefCell::new(HashMap::new()),
                 next: Cell::new(0),
--- python/skirr/__init__.py.before
+++ python/skirr/__init__.py.experiment
@@ -14,6 +14,19 @@
         for component in name.split('.'):
             app = getattr(app, component)
     return app
+
+
+def _task_runner(loop):
+    # Python's call specialization avoids rebuilding a kwargs dictionary at
+    # this boundary. The loop's task factory is still checked per request.
+    from asyncio import Task
+
+    def spawn(coro, eager):
+        if eager and loop.get_task_factory() is None:
+            return Task(coro, loop=loop, eager_start=True)
+        return loop.create_task(coro)
+
+    return spawn
 
 
 class Lifespan: