</>CodeWithKarani

The asyncio SSL Memory Leak Nobody Warns You About, and How to Work Around It

Karani GeoffreyKarani Geoffrey8 min read

Your service runs fine for a day. Then Prometheus shows a slow, relentless upward slope on resident memory, one that never comes back down no matter how quiet traffic gets. On the third day the kernel's OOM killer picks your process and ends it, the container restarts, RSS drops to baseline, and the slope begins again. You have not deployed new code. You have not changed traffic. Something is leaking, and it is not in your handlers.

If that service talks TLS and uses asyncio, whether through aiohttp, an async database driver, or a websocket gateway, you may be looking at a leak that is not your bug. There is a documented, long-lived CPython issue in the asyncio SSL transport where memory grows under connection churn, and it got materially worse from Python 3.11 onward.

The reason this one burns so many hours is that all the generic advice, "close your session properly", "use one ClientSession", "await your tasks", assumes the leak is your object graph. Here it is below your object graph, in the SSL protocol buffers, so every well-meaning code review comes back clean and the memory keeps climbing.

A known CPython issue leaks memory in the asyncio SSL transport under high TLS connection churn, worse on Python 3.11+ than 3.9, present with and without uvloop. Steps that actually help:

  • Confirm it is the leak with a minimal TLS-only reproduction and tracemalloc, ruling out your own retained objects.
  • Upgrade to the latest patch release of your Python minor version; several fixes were merged and backported to the 3.11 and 3.12 branches.
  • Contain it with per-worker request limits and a capped connection pool so RSS is recycled before OOM.
  • Alert on RSS slope, not just absolute RSS, so you catch it days before the kill.

The symptom, stated precisely

The distinguishing feature is not that memory is high. It is that memory grows monotonically as a function of the number of TLS connections opened over the process lifetime, and never as a function of concurrent load. A service handling 10 requests per second over TLS for a week can OOM while a service handling 1,000 requests per second over plain HTTP for the same week stays flat. Restarting resets it to baseline every time, which is exactly why teams reach for a restart cron and stop investigating.

TLS/SSL asyncio leaks memory

That phrase, and RSS that climbs with connection count under TLS, is the fingerprint. It shows up in aiohttp clients hammering an HTTPS upstream, in async Postgres or Redis drivers negotiating TLS per connection, and in any asyncio server terminating TLS directly rather than behind a proxy.

Why it happens, and why your code review keeps coming back clean

asyncio does not do TLS itself. It layers an SSLProtocol over the raw transport: incoming ciphertext is fed into an in-memory buffer, handed to OpenSSL to decrypt, and the plaintext is passed up to your protocol. Outgoing plaintext goes the other way. That buffering and handshake machinery is where the reported reference leaks live: objects retained when a handshake fails, waiters not cleaned up on transport close, and socket-call handling that holds references longer than it should.

Because the retained objects belong to the transport layer, they are invisible to the mental model you use when reviewing application code. You are looking for a dict that keeps growing, a cache without eviction, a task that never completes. None of those are the problem. The leak is one layer beneath the ClientSession and the await, in the plumbing that turns bytes into an encrypted stream. That is also why it correlates with Python version: the SSL protocol implementation was reworked, and the versions from 3.11 leak more than 3.9 did.

Your handlers, aiohttp ClientSession, driver pool where reviews look (and find nothing) asyncio SSLProtocol + read/write buffers retained refs on failed handshake / transport close: the leak RSS grows with connection count OpenSSL, raw socket transport, the event loop
Your objects are fine. The retained memory sits in the SSL transport layer that application-level reviews never inspect.

Step 1: Reproduce it in isolation

Before you blame CPython, prove the leak lives in the TLS path and not your code. Write the smallest possible loop that does nothing but churn encrypted connections and measure RSS.

import asyncio, os, resource
import aiohttp

def rss_mb() -> float:
    # ru_maxrss is KB on Linux, bytes on macOS
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024

async def churn(url: str, rounds: int) -> None:
    for i in range(rounds):
        # New connector each round forces a fresh TLS handshake
        async with aiohttp.TCPConnector(force_close=True) as conn:
            async with aiohttp.ClientSession(connector=conn) as session:
                async with session.get(url) as resp:
                    await resp.read()
        if i % 500 == 0:
            print(f"round {i}: RSS = {rss_mb():.1f} MB")

asyncio.run(churn("https://example.com/", 10000))

Run the same loop against an http:// URL as the control. If RSS climbs steadily on https:// and stays flat on http://, the growth is in TLS handling. This is the single most convincing piece of evidence you can bring to an issue thread or a teammate, because it removes your application entirely.

Step 2: Confirm with tracemalloc, not vibes

A rising RSS graph is suggestive; a diff of allocation snapshots is proof. Take a baseline, churn connections, take another snapshot, and print the biggest growers.

import tracemalloc, asyncio

async def main():
    tracemalloc.start(25)
    await churn("https://example.com/", 2000)   # warm up
    snap1 = tracemalloc.take_snapshot()

    await churn("https://example.com/", 8000)    # the run under test
    snap2 = tracemalloc.take_snapshot()

    for stat in snap2.compare_to(snap1, "traceback")[:5]:
        print(stat)
        for line in stat.traceback.format():
            print("   ", line)

asyncio.run(main())

When it is this leak, the top allocations trace back into asyncio/sslproto.py and the SSL buffer objects, not into your modules. If the top growers point at your own dict or list, congratulations, it is your bug after all and a normal fix applies. Read the traceback; it decides which world you are in.

Step 3: Upgrade before you work around

Several patches for this were merged and backported to the 3.11 and 3.12 branches, covering reference leaks on failed handshakes and cleanup on transport closure. The first thing to try is therefore the cheapest: move to the latest patch release of your Python minor version and re-run the reproduction from Step 1.

python3 --version
# then rebuild your image on the newest 3.11.x / 3.12.x / 3.13.x patch
# and re-run the churn reproduction to compare the RSS slope

Do not assume the upgrade fixed it because the version number went up. Re-measure. The reproduction thread is long precisely because "fixed" landed incrementally, and residual growth was still reported on some patch levels. Your own before-and-after RSS slope is the only trustworthy signal.

Step 4: Contain what you cannot yet fix

If you are pinned to an interpreter that still leaks, or the residual growth is small but real, the pragmatic answer is bounded worker lifetime plus a capped connection pool. This is containment, not repair, and I say that plainly so nobody files it as done.

Recycle workers by request count. Both uvicorn and gunicorn support a maximum requests per worker, after which the process is replaced. A replaced process starts at baseline RSS, so the leak can never reach OOM territory:

# gunicorn managing uvicorn workers: recycle each after ~10k requests,
# with jitter so they do not all restart at once
gunicorn app:app \
  -k uvicorn.workers.UvicornWorker \
  --workers 4 \
  --max-requests 10000 \
  --max-requests-jitter 1000

Cap concurrent TLS connections. The leak scales with connections opened, so pooling and reusing connections reduces the churn that drives it. For aiohttp clients, share one session and bound the connector:

# One long-lived session, bounded pool, keep-alive to avoid re-handshaking
connector = aiohttp.TCPConnector(limit=100, limit_per_host=20, ttl_dns_cache=300)
session = aiohttp.ClientSession(connector=connector)

Reusing a connection avoids a fresh handshake, and fewer handshakes means fewer opportunities for the handshake-path leak to fire. This also happens to be good practice regardless of the bug.

Verification

You have contained or fixed it when the RSS slope over a fixed number of connections flattens. Measure the same workload before and after and compare the trend, not a single reading:

# Sample RSS of the process every 10s during a sustained TLS load test
while true; do
  ps -o rss= -p "$(pgrep -f 'app:app' | head -1)" \
    | awk '{printf "%.1f MB\n", $1/1024}'
  sleep 10
done

A patched or well-contained service shows RSS rising to a plateau and holding, or sawtoothing as workers recycle, both acceptable. A still-leaking service shows a straight line to the ceiling. If you run under Kubernetes, the same shape appears in the container memory graph, and a periodic OOMKill with exit code 137 is the tell that containment is not aggressive enough yet. The mechanism behind that kill is worth understanding in its own right; I cover it in what really happens when your server runs out of RAM.

What people get wrong

"Use your ClientSession properly." This is the reflexive answer on every forum, and it is correct advice for a different bug. Sharing one session and closing it cleanly matters, but it does not touch a leak that lives below the session in the SSL transport. Teams burn days refactoring session management and the graph keeps climbing.

Switching to uvloop to fix it. uvloop is a faster event loop, not a different TLS stack for this purpose, and the growth is reported both with and without it. Change loops for throughput if you like, but do not log it as the memory fix.

A blind restart cron with no upgrade path. Restarting every few hours keeps you alive, and there is no shame in shipping it under pressure. The failure is stopping there. A restart cron with no ticket to upgrade the interpreter is how a temporary bandage becomes a permanent architecture that everyone is afraid to remove two years later.

When it is still broken

  1. Terminate TLS at a proxy. If your Python process talks plain HTTP to a local nginx or Envoy that handles TLS, the asyncio SSL path is out of your process entirely. This sidesteps the leak completely and is often the right architecture anyway; see my nginx reverse proxy and TLS setup.
  2. Check your OpenSSL, not just Python. The leak interacts with the SSL library underneath. Record both the Python patch version and the linked OpenSSL version when you compare runs, because a change in either can move the numbers.
  3. Bound the pool harder. If recycling by request count still lets RSS spike between restarts, lower --max-requests and tighten limit_per_host. You are trading a little connection reuse for a lot of predictability.
  4. Track the upstream issue across versions. Because fixes landed incrementally, "resolved on 3.12" does not automatically mean resolved on your 3.11. Retest your reproduction on each interpreter bump rather than trusting a changelog line.

The uncomfortable truth is that this is one of the cases where the honest answer is "it is not fully your bug, and it is not fully fixed everywhere". Confirm it is the SSL path, upgrade to the newest patch you can, contain what remains with bounded worker lifetimes, and alert on the slope so the next occurrence is a graph you noticed on Tuesday rather than a page at 3am on Friday.

Frequently asked questions

Is there really a memory leak in Python's asyncio SSL transport?
Yes. A long-standing CPython issue documents RSS growth in high-concurrency asyncio TLS workloads, noticeably worse on Python 3.11 and later than on 3.9, with or without uvloop. Several fixes have been merged and backported to the 3.11 and 3.12 branches, but the reproduction thread ran long enough that teams still hit residual growth on older patch releases, so the practical advice is to upgrade to the latest patch version and verify.
How do I know it is the SSL leak and not a bug in my own code?
Reproduce with a minimal TLS client or server that does nothing but open and close encrypted connections in a loop, and watch RSS with tracemalloc snapshots. If RSS climbs steadily under pure connection churn with no application objects retained, and the same loop over plain HTTP does not leak, the growth is in the SSL transport layer, not your objects.
What is the safest workaround if I cannot upgrade Python?
Recycle workers on a bounded lifetime. Set a max-requests limit per worker (uvicorn and gunicorn both support this) so each process is replaced before its RSS grows dangerous, and cap concurrent TLS connections with a connection-pool limit. This is a containment strategy, not a fix, but it keeps a leaking service alive and predictable until you can move to a patched interpreter.
Does using uvloop avoid the asyncio SSL leak?
No. The reported growth appears both with the stock asyncio event loop and with uvloop, because the leak is in the SSL protocol and transport handling rather than the loop implementation. Switching loops changes performance, not this behaviour, so do not treat uvloop as the fix.
#asyncio#Python#TLS#Memory Leak#aiohttp
Keep reading

Related articles