Skip to content

Benchmarking uvloop Against the Default Event Loop

Someone on the team swapped in uvloop, the dashboards did not move, and now there is a debate about whether it was worth the extra native dependency. Or the opposite: a blog post promised "2–4x faster asyncio", the change shipped, and p99 latency is unchanged because the service spends 85% of its time serialising JSON and waiting on Postgres. Both situations come from the same mistake — treating the loop backend as a global speed dial instead of measuring how much of your request time the loop actually owns. This guide builds a repeatable A/B harness: a transport-only benchmark that isolates the loop, a workload-shaped benchmark that includes your real handler, and a profile that tells you what fraction of wall time the loop implementation can influence at all.

Prerequisites

  • Python 3.12+ so both backends can be selected with asyncio.Runner(loop_factory=...); on 3.11 the same factory argument works on asyncio.Runner, only asyncio.run(loop_factory=...) is 3.12-only.
  • uvloop on Linux or macOS: pip install uvloop. It has no Windows build, so the harness keeps the stdlib loop as the baseline everywhere.
  • Loop configuration basics from Event Loop Configuration, and the production selection code in how to properly configure asyncio event loops for production, which this page assumes is already in place.
  • A quiet machine. Loop benchmarks measure microseconds per callback; a noisy neighbour or CPU frequency scaling will swamp the difference you are trying to see.

The question to answer is not "is uvloop faster" — on transport-heavy microbenchmarks it nearly always is — but "how much of my latency is spent in the part uvloop replaces".

What a loop backend can and cannot speed up 4 stacked layers from Your handler to Loop internals. What a loop backend can and cannot speed up Your handler validation business logic ORM calls Serialisation json.loads json.dumps compression Downstream waits database HTTP dependency cache Loop internals socket readiness buffers callback dispatch uvloop rewrites only the bottom layer — the gain is capped by that layer's share.

1. Select the backend from one switch

A benchmark that compares two code paths is worthless if the paths differ in anything but the loop. Route the backend choice through a single factory so the server, the client and every helper run identically, and make the active backend visible in the output so a silent fallback cannot contaminate the numbers.

# pip install uvloop
import asyncio
import os
import sys


def loop_factory():
    backend = os.environ.get("LOOP", "asyncio")
    if backend == "uvloop":
        import uvloop                      # fail loudly: no silent fallback in a benchmark
        return uvloop.new_event_loop()
    return asyncio.new_event_loop()        # SelectorEventLoop on Unix


def run(main):
    with asyncio.Runner(loop_factory=loop_factory) as runner:
        loop = runner.get_loop()
        print(f"backend={type(loop).__module__}.{type(loop).__name__} "
              f"python={sys.version.split()[0]}", file=sys.stderr)
        return runner.run(main())

Verify: LOOP=uvloop python bench.py prints backend=uvloop.Loop, and running without the variable prints backend=asyncio.unix_events._UnixSelectorEventLoop. If the uvloop run prints the selector loop, the import is being shadowed or the wrong interpreter is on the path — stop and fix that before measuring anything.

2. Measure the transport with an echo benchmark

Start with the case where the loop matters most: many connections exchanging small messages, with a handler that does almost nothing. This measures the loop's socket readiness handling, buffer management and callback dispatch — the exact code uvloop replaces with libuv.

import asyncio
import time

PAYLOAD = b"x" * 128 + b"\n"


async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
    while line := await reader.readline():
        writer.write(line)
        await writer.drain()
    writer.close()


async def client(port: int, deadline: float, counts: list[int]) -> None:
    reader, writer = await asyncio.open_connection("127.0.0.1", port)
    n = 0
    while time.perf_counter() < deadline:
        writer.write(PAYLOAD)
        await writer.drain()
        await reader.readline()
        n += 1
    counts.append(n)
    writer.close()
    await writer.wait_closed()


async def echo_bench(conns: int = 200, seconds: float = 10.0) -> float:
    server = await asyncio.start_server(handle, "127.0.0.1", 0)
    port = server.sockets[0].getsockname()[1]
    counts: list[int] = []
    deadline = time.perf_counter() + seconds
    async with asyncio.TaskGroup() as tg:
        for _ in range(conns):
            tg.create_task(client(port, deadline, counts))
    server.close()
    await server.wait_closed()
    return sum(counts) / seconds

Verify: run each backend at least five times and keep every result, not just the best. On a typical Linux box the echo rate with uvloop lands well above the selector loop — often around double — and the spread between runs should be a few percent. A spread wider than the gap between backends means the machine is too noisy to conclude anything.

Two benchmarks answer two questions 2 columns contrasting echo benchmark, workload benchmark. Two benchmarks answer two questions echo benchmark what is the most uvloop can do? 128-byte lines, no handler work 200 connections on loopback loop share of time is high ratio is an upper bound workload benchmark what will my service get? real parse and serialise cost recorded downstream latency loop share matches production ratio is the forecast Report both ratios; the distance between them is the answer, not noise.

3. Benchmark the shape of your real workload

The echo number is an upper bound, not a forecast. Now replace the trivial handler with one that does what your service does per request: parse a payload, await a simulated dependency with realistic latency, and serialise a response. The dependency sleep and the CPU work stay identical across backends, so any change in throughput is the loop's share alone.

import asyncio
import json
import random

DEPENDENCY_MS = (2.0, 8.0)       # measured p50..p95 of your real downstream call
DOC = {"id": 1, "items": [{"sku": i, "qty": i % 7} for i in range(40)]}


async def realistic_handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
    rng = random.Random(42)                          # same delays for both backends
    while line := await reader.readline():
        request = json.loads(line)                    # CPU: parse
        await asyncio.sleep(rng.uniform(*DEPENDENCY_MS) / 1000)  # I/O: downstream wait
        body = json.dumps({"echo": request, "doc": DOC})         # CPU: serialise
        writer.write(body.encode() + b"\n")
        await writer.drain()
    writer.close()

Drive it with the same client, sending a small JSON line instead of the fixed payload, and record per-request latency with time.perf_counter() around each round trip so you can report p50 and p99 as well as throughput.

Verify: compare the uvloop/asyncio ratio from this run with the echo ratio from step 2. If the echo benchmark showed 2x and this one shows 1.1x, that gap is the finding: most of the request is spent in code the loop backend does not touch.

4. Profile the fraction of time the loop owns

Throughput ratios tell you that the gain shrank; a profile tells you why, and predicts the gain before you deploy. Sample a running process and bucket the stacks into loop internals versus your code.

import cProfile
import pstats

LOOP_MARKERS = ("selector_events", "base_events", "events.py", "streams.py",
                "selectors.py", "transports")


def loop_share(profile_path: str) -> float:
    stats = pstats.Stats(profile_path)
    total = stats.total_tt
    loop_tt = sum(tt for (filename, _, _), (_, _, tt, _, _) in stats.stats.items()
                  if any(m in filename for m in LOOP_MARKERS))
    return loop_tt / total


# Profile the stdlib-loop run only: uvloop's internals are C and invisible to cProfile.
# cProfile.run("run(workload_bench)", "asyncio.prof")
# print(f"loop share of self time: {loop_share('asyncio.prof'):.0%}")

cProfile inflates Python-level overhead, so treat the share as an estimate and cross-check it with a sampling profiler such as py-spy record --native on a live instance, which is covered in profiling asyncio applications with py-spy.

Verify: the share from the echo benchmark should be high (most self time in selector_events and streams), and the share from the workload benchmark noticeably lower. Plug the workload share into the arithmetic in the next step and the prediction should land within a few percent of the ratio you measured in step 3.

Predicted overall speedup by loop share 5 bars comparing loop owns 10% of request time with the others. Predicted overall speedup by loop share loop owns 10% of request time 1.06x loop owns 30% of request time 1.22x loop owns 50% of request time 1.43x loop owns 70% of request time 1.72x loop owns 90% of request time 2.17x Computed as 1 / ((1 - f) + f / 2.5) — the loop portion assumed 2.5x faster under uvloop. A database-bound API sits near the top rows; a thin proxy sits near the bottom.

5. Decide with Amdahl's law, not the headline number

If a fraction f of request time is loop overhead and uvloop runs that part s times faster, the whole request speeds up by 1 / ((1 - f) + f / s). With s around 2.5 — a reasonable figure for transport-heavy code — a service whose loop share is 10% gains about 6%, while a proxy whose loop share is 70% gains nearly 70%.

def predicted_speedup(loop_share: float, loop_speedup: float = 2.5) -> float:
    return 1 / ((1 - loop_share) + loop_share / loop_speedup)


for share in (0.1, 0.3, 0.5, 0.7, 0.9):
    print(f"loop share {share:.0%}: {predicted_speedup(share):.2f}x overall")

Adopt uvloop when the predicted and measured gain is material and the operational cost is acceptable: a compiled wheel per platform, no Windows support, and slightly different behaviour in debug tooling. For proxies, WebSocket fan-out, and gateways with thin handlers, it usually is. For a CRUD API dominated by the database, spend the effort on connection pool sizing instead.

Verify: the decision is written down with three numbers — echo ratio, workload ratio, loop share — so the next person who asks "should we use uvloop" reads the measurement instead of repeating the debate.

Verification

A trustworthy comparison satisfies all of these:

  • Backend confirmed per run: every result line carries the backend= banner, and no uvloop run silently fell back to the selector loop.
  • Repeated, not cherry-picked: at least five runs per backend, reported as median and spread; the spread is smaller than the difference being claimed.
  • Two benchmarks, two ratios: an echo ratio (upper bound) and a workload ratio (forecast), with the workload ratio never larger than the echo ratio.
  • Prediction matches measurement: the Amdahl estimate from the profiled loop share lands close to the workload ratio; if it does not, the profile is attributing time to the wrong bucket.
  • Latency, not only throughput: p99 per round trip is reported for both backends, because a loop that is faster on average can still have a worse tail under GC or buffer pressure.

Pitfalls & edge cases

  • Client and server in the same loop. Running the load generator inside the process under test means the client's overhead is also sped up by uvloop, inflating the ratio. For a publishable number, run the client in a separate process pinned to different cores with taskset.
  • Debug mode left on. PYTHONASYNCIODEBUG=1 or -X dev adds slow-callback tracking to the stdlib loop that uvloop implements differently, which skews the comparison heavily in uvloop's favour. Benchmark both with debug off, as in production.
  • Loopback hides the network. 127.0.0.1 has no latency or packet loss, so the loop share looks larger than it will on a real network. Add a realistic dependency delay (step 3) before drawing conclusions.
  • Frequency scaling and turbo. A CPU that ramps up during the first run makes whichever backend runs second look faster. Warm up for a few seconds, alternate backends between runs, and set the governor to performance on the benchmark host.
  • Libraries with their own loop assumptions. Some tools check isinstance(loop, asyncio.SelectorEventLoop) or rely on loop._selector. Run the service's own test suite under uvloop before trusting a benchmark that only exercised streams.

Frequently Asked Questions

How much faster is uvloop than the default asyncio event loop?

On transport-heavy microbenchmarks such as echo servers with many small messages, uvloop commonly runs around two to four times faster than the stdlib selector loop. The gain in a real service is far smaller whenever request time is dominated by application code, serialisation or downstream waits, because uvloop only speeds up the loop's own share of the work.

How do I select uvloop in Python 3.12 without the event loop policy API?

Pass a loop factory: asyncio.run(main(), loop_factory=uvloop.new_event_loop), or use asyncio.Runner(loop_factory=uvloop.new_event_loop) which also works on Python 3.11. Recent uvloop releases additionally provide uvloop.run(main()). The policy-based uvloop.install() still works but the policy system is deprecated in newer Python versions.

Why didn't switching to uvloop improve my API latency?

Most likely the loop owns only a small fraction of each request. If JSON parsing, ORM work and database round trips account for ninety percent of the time, even an infinitely fast loop improves latency by at most about ten percent. Profile the loop share first and apply Amdahl's law to predict the gain before changing the backend.

Can I benchmark uvloop with cProfile?

Only indirectly. uvloop's internals are compiled C code, so cProfile cannot see inside them. Profile the stdlib-loop run to estimate the fraction of time spent in selector_events, base_events and streams, then use that share to predict the uvloop gain, and confirm with a sampling profiler that can record native frames.