← Connection Pooling & Resource Management

Django Channels and PostgreSQL Connection Exhaustion: Managing WebSocket Database Connections

Your WebSocket consumers are holding database connections hostage. They have been doing it for hours. Allow me to negotiate a release.

The Waiter of Gold Lapel · Updated Mar 20, 2026 Published Mar 5, 2026 · 38 min read
The connection storing this image has been occupied by a WebSocket since Tuesday.

Good evening. I see your connection count is climbing.

You built a real-time feature with Django Channels. Chat, notifications, live dashboards, collaborative editing — any of the things that require a persistent WebSocket connection between browser and server. Everything worked in development with three test connections. Staging looked fine with twenty. Then production happened, and at 150 concurrent users your logs started showing this:

FATAL: sorry, too many clients already

The frustrating part: your HTTP views were handling thousands of requests per hour with no connection issues. It was specifically the WebSocket feature — the one you shipped to great fanfare last sprint — that brought the database to its knees. Not with query volume. Not with slow queries. Just by existing.

I find this behaviour — creating connections without a mechanism for closing them — to be the infrastructural equivalent of opening every window in the house during winter and then complaining about the heating bill. The connections are not the problem. The absence of a plan for when they accumulate is.

The root cause is a mismatch between how Django manages database connections and how WebSocket consumers use them. Django's connection lifecycle was designed for HTTP: short-lived request, short-lived connection. WebSocket consumers are long-lived. They hold threads. Those threads hold connections. And those connections never recycle.

This is not a Django bug. It is a design assumption that held true for fifteen years of request/response web development and became false the moment we introduced persistent connections. The Django team knows this. The documentation says as much, if you know where to look. But the tutorials and Stack Overflow answers that got your Channels feature running rarely mention it — and by the time you discover the problem, you are debugging it at 2 AM on a Saturday.

What follows is exactly why this happens, how to diagnose it, what the numbers look like, and what your options are — ranked from quick-fix to permanent solution. We shall be thorough, because the half-understood version of this problem is what gets people into trouble in the first place.

Why SyncConsumer exhausts your connections

The default Django Channels consumer is the WebsocketConsumer, which inherits from SyncConsumer. "Sync" means the consumer's methods — connect(), receive(), disconnect() — run in a regular thread, not on the async event loop. Channels allocates one thread per connected WebSocket.

A standard SyncConsumer with ORM access
# A typical Django Channels consumer.
# Looks harmless. Is not.

from channels.generic.websocket import WebsocketConsumer
from myapp.models import ChatMessage
import json

class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
        self.accept()

    def receive(self, text_data):
        data = json.loads(text_data)
        # This ORM call runs in the consumer's thread.
        # Each thread holds its own database connection.
        # The connection stays open for the lifetime of the WebSocket.
        message = ChatMessage.objects.create(
            room=self.room_name,
            user=self.scope["user"],
            content=data["message"],
        )
        self.send(text_data=json.dumps({
            "message": message.content,
            "user": str(message.user),
            "timestamp": message.created_at.isoformat(),
        }))

    def disconnect(self, close_code):
        pass  # Connection closed. Thread ends. Database connection... maybe closes.

Here is the problem, stated plainly: Django stores database connections per-thread. When your consumer's thread makes its first ORM call, Django opens a connection to PostgreSQL and stores it in a thread-local variable. That connection remains open for the lifetime of the thread. The thread's lifetime is the WebSocket's lifetime. A user who keeps a tab open for four hours holds a database connection for four hours.

I should be precise about what "holds a connection" means at the PostgreSQL level. Each connection is a postgres backend process. It consumes approximately 5-10 MB of memory. It occupies a slot in max_connections. It holds file descriptors. It participates in PostgreSQL's process-per-connection model. An idle connection that is doing no work costs nearly as much as an active one, because the backend process exists regardless of whether it is executing queries.

With HTTP, this design is sensible. A request handler's thread lives for 50-500ms. The database connection is opened, used, and either closed or returned to a pool quickly. Even with CONN_MAX_AGE set to reuse connections, the churn keeps the total count manageable. A Django application serving 1,000 requests per second might peak at 20-30 concurrent database connections, because each connection is used and released in under a second.

With WebSocket consumers, there is no churn. Connections accumulate. 200 concurrent WebSockets means 200 threads, means 200 database connections held indefinitely. PostgreSQL's default max_connections is 100.

The arithmetic that ends your weekend
# Django Channels connection math — the arithmetic that ends your weekend

# ASGI server configuration (uvicorn/daphne)
# Default ASGI_THREADS: 1 per SyncConsumer (managed by ThreadPoolExecutor)

# Scenario: a chat application
# - 200 users connected via WebSocket
# - Each WebSocket gets a SyncConsumer
# - Each SyncConsumer runs in its own thread
# - Each thread opens a Django database connection
# - Django's CONN_MAX_AGE is irrelevant — the thread never finishes a request cycle

# Connections to PostgreSQL:
#   200 WebSocket consumers = 200 threads = 200 database connections

# PostgreSQL default:
max_connections = 100

# 200 > 100.
# FATAL: sorry, too many clients already

# But wait — you also have:
# - HTTP requests through the same ASGI server:    ~20 connections
# - Celery workers (4 processes, 2 threads each):  +8 connections
# - Django management commands (migrate, etc.):    +2 connections
# - Monitoring and admin:                          +3 connections
#                                           Total: ~233 connections

# Against max_connections = 100.
# This fails the moment your chat gets popular.

# The cruel part: with HTTP requests, connections are short-lived.
# A 200ms Django view holds a connection for 200ms.
# A WebSocket consumer holds a connection for MINUTES or HOURS.
# 50 users chatting for an hour = 50 connections held for an hour.
# No recycling. No sharing. Just accumulation.

The numbers are unambiguous. At 200 concurrent WebSocket users — a modest number for any real-time feature that works — you have already exceeded PostgreSQL's default connection limit by a factor of two. Add your HTTP traffic, background workers, and administrative connections, and the math becomes genuinely hostile.

And I should note: 200 concurrent WebSocket users is not 200 total users. If your feature is a notification system that keeps a WebSocket open while the user is on any page, your concurrent WebSocket count is your concurrent user count. An application with 2,000 daily active users might have 400-600 concurrent WebSocket connections during peak hours. The connection math fails well before the feature feels "popular."

The instinct at this point is to raise max_connections. I would advise against it, or at least against treating it as the solution. PostgreSQL is not designed to handle hundreds of active connections efficiently. Each connection is a process. Process-level context switching, shared buffer contention, lock management overhead — all of these degrade as connection count rises. The PostgreSQL project's own wiki recommends keeping active connections in the low hundreds even on large servers. Setting max_connections = 500 to accommodate your WebSocket layer will not produce a fatal error, but it will produce degraded query performance for every part of your application. You are treating the symptom and worsening the disease.

Diagnosing the problem from PostgreSQL's side

Before we discuss solutions, allow me a brief diagnostic interlude. You suspect connection exhaustion but are not certain. Perhaps you are seeing intermittent FATAL: sorry, too many clients already errors, or perhaps your application is merely slow and you suspect the connection count is involved. Here is how to confirm it from PostgreSQL's perspective.

Diagnostic queries for connection exhaustion
-- Diagnosing connection exhaustion from the PostgreSQL side.
-- Run these when you suspect Django Channels is hoarding connections.

-- 1. How many connections exist, and who holds them?
SELECT
    usename,
    application_name,
    state,
    count(*) AS connection_count,
    min(backend_start) AS oldest_connection,
    now() - min(backend_start) AS oldest_age
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY usename, application_name, state
ORDER BY connection_count DESC;

-- Typical output for a Channels application with connection exhaustion:
--  usename  | application_name |  state  | connection_count |  oldest_age
-- ----------+------------------+---------+------------------+-----------
--  appuser  |                  | idle    |              147 | 04:23:17
--  appuser  |                  | active  |                3 | 00:00:01
--  appuser  | celery           | idle    |                8 | 01:12:44
--  postgres | psql             | active  |                1 | 00:00:00

-- 147 idle connections from appuser. That is your WebSocket layer.
-- They have been idle for over four hours.
-- They are not running queries. They are not doing anything.
-- They are simply existing — and occupying max_connections slots.

-- 2. How close are we to max_connections?
SELECT
    max_conn,
    current_conn,
    max_conn - current_conn AS remaining,
    round(100.0 * current_conn / max_conn, 1) AS pct_used
FROM
    (SELECT setting::int AS max_conn FROM pg_settings WHERE name = 'max_connections') AS m,
    (SELECT count(*) AS current_conn FROM pg_stat_activity) AS c;

-- 3. Which connections have been idle the longest?
SELECT
    pid,
    usename,
    application_name,
    state,
    now() - state_change AS idle_duration,
    now() - backend_start AS connection_age,
    query
FROM pg_stat_activity
WHERE state = 'idle'
  AND backend_type = 'client backend'
ORDER BY state_change ASC
LIMIT 20;

The telltale pattern is unmistakable: a large number of connections in the idle state, all belonging to the same application user, with connection ages measured in hours rather than seconds. These are your WebSocket consumer threads. They opened a connection for their first ORM call and never closed it. They are not running queries. They are not in a transaction. They are simply holding a slot.

If you see 147 idle connections from appuser with an oldest age of four hours, you do not have a slow query problem or an indexing problem. You have an occupancy problem. The rooms are all booked and the guests are not checking out.

The second query — checking proximity to max_connections — is the one to wire into your monitoring. When connection usage exceeds 80% of max_connections, you are one traffic spike away from the FATAL error. I have seen teams run at 90% utilization for weeks, with the occasional dropped connection during peak hours, before they realize the trend is monotonically increasing. By the time it reaches 100%, every new connection attempt — from HTTP views, from Celery, from admin tools — fails. Not just the WebSocket consumers. Everything.

The third query — checking for long-idle connections — is useful for distinguishing WebSocket-held connections from normal HTTP connection reuse. With CONN_MAX_AGE=600, a healthy HTTP connection might be idle for up to 10 minutes between requests. A connection that has been idle for four hours is not an HTTP connection. It is a WebSocket consumer's thread-local connection, untouched since the last message that consumer processed.

Why CONN_MAX_AGE does not save you

The first instinct is to reach for Django's CONN_MAX_AGE setting. After all, it controls how long connections are reused. Setting it to a lower value should force connections to close sooner. The logic is appealing. It is also incorrect.

CONN_MAX_AGE in WebSocket context
# Django's CONN_MAX_AGE: designed for HTTP, broken for WebSocket

# settings.py
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "myapp",
        "HOST": "localhost",
        "PORT": "5432",
        "USER": "appuser",
        "PASSWORD": "secret",
        "CONN_MAX_AGE": 600,  # reuse connections for 10 minutes
    }
}

# How CONN_MAX_AGE works with HTTP (Django's request/response cycle):
# 1. Request arrives
# 2. Django opens or reuses a connection (stored per-thread)
# 3. Request handler runs queries
# 4. Response sent
# 5. Django checks CONN_MAX_AGE:
#    - If connection age < CONN_MAX_AGE, keep it open for next request
#    - If connection age >= CONN_MAX_AGE, close it
# 6. This cleanup happens in signals.request_finished
#
# With WebSocket consumers, request_finished NEVER fires.
# The "request" never finishes. The WebSocket stays open.
# CONN_MAX_AGE becomes meaningless.
# Connections are never checked for staleness.
# Connections are never closed by Django's lifecycle.
#
# If you set CONN_MAX_AGE=0 (default), Django should close
# connections after each "request" — but again, the request
# never ends, so the close never happens.
#
# You are outside Django's connection lifecycle entirely.

The fundamental issue: Django's connection cleanup runs in the request_finished signal handler. This signal fires after every HTTP request completes. It is part of Django's request/response lifecycle — the same lifecycle that WebSocket consumers sidestep entirely.

When a WebSocket consumer is running, there is no request finishing. No signal fires. No cleanup happens. CONN_MAX_AGE controls behavior that never triggers. You could set CONN_MAX_AGE=1 — one second — and a WebSocket consumer's connection would still live for four hours, because the code that checks CONN_MAX_AGE never executes.

To understand why, it helps to look at where the cleanup actually lives in Django's source code:

The connection lifecycle in Django's source
# Where CONN_MAX_AGE cleanup actually lives in Django's source.
# Understanding this explains why WebSocket consumers are orphaned.

# django/db/__init__.py
from django.core.signals import request_started, request_finished

def close_old_connections(**kwargs):
    for conn in connections.all():
        conn.close_if_unusable_or_obsolete()

# These two signal handlers are the ENTIRE connection lifecycle:
request_started.connect(close_old_connections)
request_finished.connect(close_old_connections)

# django/db/backends/base/base.py
def close_if_unusable_or_obsolete(self):
    if self.connection is not None:
        if self.get_rollback():
            self.close()  # connection had an error
        elif self.connection_is_old():
            self.close()  # CONN_MAX_AGE exceeded
        elif not self.is_usable():
            self.close()  # connection dropped

def connection_is_old(self):
    # This is what CONN_MAX_AGE controls
    if self.settings_dict['CONN_MAX_AGE'] is None:
        return False  # persistent connections never expire
    return time.monotonic() - self._connection_created >= self.settings_dict['CONN_MAX_AGE']

# The chain of causation:
# 1. request_started / request_finished signals trigger close_old_connections
# 2. close_old_connections checks each connection against CONN_MAX_AGE
# 3. WebSocket consumers never trigger request_started or request_finished
# 4. Therefore close_old_connections never runs
# 5. Therefore connections are never checked, never closed
# 6. Therefore they accumulate until max_connections is reached

The chain of causation is straightforward. close_old_connections() is triggered by request_started and request_finished signals. These signals are emitted by Django's HTTP request handling machinery. WebSocket consumers do not participate in that machinery. Therefore close_old_connections() never runs. Therefore CONN_MAX_AGE is never checked. Therefore connections persist.

Django 4.1 added CONN_HEALTH_CHECKS, which verifies connection liveness before use. This is helpful for detecting connections that PostgreSQL terminated due to idle timeout (idle_in_transaction_session_timeout or tcp_keepalives_idle) or server restart. It does not help with the accumulation problem. A healthy connection that has been open for three hours is still a healthy connection occupying a max_connections slot. CONN_HEALTH_CHECKS answers the question "is this connection still working?" but not "should this connection still exist?"

I should note one partial exception. If you set PostgreSQL's idle_session_timeout (available since PostgreSQL 14) to, say, 30 minutes, PostgreSQL itself will terminate connections that have been idle for that duration. This works — the connection is closed server-side regardless of what Django does or does not do. But it is a blunt instrument. It applies to all connections, including your HTTP views' reusable connections that are legitimately idle between requests. And when a WebSocket consumer's connection is terminated by PostgreSQL, the next ORM call from that consumer will fail with a connection reset by peer error unless you have CONN_HEALTH_CHECKS enabled to detect the dead connection and open a new one. At which point the new connection will also eventually be terminated after 30 minutes of idleness, and the cycle repeats.

The connection lifecycle model is not broken. It simply was not designed for long-lived consumers. Django's authors knew this. The Django async documentation recommends database_sync_to_async specifically because it moves ORM calls out of the consumer's thread. But that introduces its own connection dynamics.

AsyncWebsocketConsumer and database_sync_to_async: better, not solved

The community's standard advice is to switch from WebsocketConsumer to AsyncWebsocketConsumer and wrap all ORM calls in database_sync_to_async. This is correct advice. It is also incomplete advice, and the incompleteness is precisely where the next round of connection problems originates.

AsyncConsumer with database_sync_to_async
# "Just use AsyncWebsocketConsumer" — the advice that is half right.

from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from myapp.models import ChatMessage
import json

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
        await self.accept()

    async def receive(self, text_data):
        data = json.loads(text_data)
        # database_sync_to_async wraps the ORM call in a thread.
        # This IS better than SyncConsumer — the thread is temporary.
        # But each call still gets its own thread from the pool.
        message = await database_sync_to_async(self._save_message)(
            data["message"]
        )
        await self.send(text_data=json.dumps({
            "message": message.content,
            "user": str(message.user),
        }))

    def _save_message(self, content):
        return ChatMessage.objects.create(
            room=self.room_name,
            user=self.scope["user"],
            content=content,
        )

# Better — but database_sync_to_async uses a ThreadPoolExecutor.
# Default pool size: 40 threads (ASGI_THREADS or thread_sensitive default).
# Each thread that touches Django ORM opens a database connection.
# Django's connection handling per thread:
#   - Opens connection on first query
#   - CONN_MAX_AGE=0 (default): closes after each "request"
#   - But in a thread pool, "request" boundaries are ambiguous
#   - Connections accumulate. Threads are reused but connections linger.

AsyncWebsocketConsumer runs on the ASGI event loop. It does not consume a thread per WebSocket. This alone eliminates the 1:1 thread-to-WebSocket problem. 200 WebSocket connections share a single event loop thread. The event loop can handle thousands of concurrent WebSocket connections with negligible memory overhead, because it uses cooperative multitasking rather than OS threads.

But the Django ORM is synchronous. You cannot call ChatMessage.objects.create() from an async context without blocking the event loop. If you do, you freeze every WebSocket connection sharing that loop for the duration of the database call — typically 1-10ms for a simple write, potentially much longer for a complex query or under load. database_sync_to_async solves this by dispatching the ORM call to a ThreadPoolExecutor. The ORM call runs in a thread from the pool, the result is awaited asynchronously, and the consumer continues.

The connection question shifts: instead of one connection per WebSocket, you now have one connection per thread in the pool. The default pool size is controlled by the ASGI_THREADS environment variable, which defaults to 40 in most ASGI server configurations. 40 threads means up to 40 database connections. That is dramatically better than 200 — but still 40 connections from a single process, and they accumulate the same way the SyncConsumer connections did, because the threads in the pool are long-lived and Django's per-thread connection storage applies to them as well.

The two modes of database_sync_to_async
# database_sync_to_async: the two modes and their connection implications

from channels.db import database_sync_to_async

# Mode 1: thread_sensitive=True (DEFAULT)
# Runs the function in a SINGLE shared thread.
# All thread_sensitive calls serialize through one thread.
# ONE database connection. But also ONE bottleneck.
@database_sync_to_async(thread_sensitive=True)
def get_recent_messages(room_name):
    return list(
        ChatMessage.objects.filter(room=room_name)
        .order_by("-created_at")[:50]
        .values("content", "user__username", "created_at")
    )

# Mode 2: thread_sensitive=False
# Runs in a thread from the general ThreadPoolExecutor.
# Concurrent execution. Multiple database connections.
# Better throughput, higher connection usage.
@database_sync_to_async(thread_sensitive=False)
def save_message(room_name, user, content):
    return ChatMessage.objects.create(
        room=room_name,
        user=user,
        content=content,
    )

# The trade-off:
# thread_sensitive=True  -> 1 connection, serialized execution
# thread_sensitive=False -> N connections (up to pool size), parallel execution
#
# Most tutorials set thread_sensitive=False for "better performance"
# without mentioning the connection cost.
# With 200 concurrent WebSockets all saving messages,
# you can exhaust the thread pool AND the database connections simultaneously.

The thread_sensitive parameter adds another dimension. When True (the default), the call runs in a single shared thread — the so-called "main thread" for backward compatibility with code that is not thread-safe. One thread, one connection, completely serialized. When False, it runs in the general thread pool. Multiple threads, multiple connections, parallel execution.

Neither option is categorically correct. thread_sensitive=True limits you to sequential database access — fine for low-frequency WebSocket events, unacceptable for high-throughput data feeds. If you have 200 consumers and they all trigger a thread_sensitive=True ORM call in the same second, those 200 calls queue up behind a single thread. At 5ms per call, you are looking at one second of serialized processing. Your first consumer gets its result in 5ms. Your 200th consumer waits for a full second. That kind of latency tail is what makes real-time features feel broken.

thread_sensitive=False gives you concurrency but opens the connection accumulation problem in the thread pool. With a pool of 40 threads, all 200 calls can be serviced concurrently (10 at a time across the pool with 5ms latency), but each of those 40 threads holds its own Django database connection. 40 connections is better than 200, but it is still 40 connections per ASGI worker process that never close unless you take explicit action.

You are choosing between a throughput bottleneck and a connection bottleneck. The correct choice depends on your workload, and that is precisely the kind of ambiguity that causes teams to pick one, discover the problem with their choice three months later, and switch to the other — discovering that problem three months after that.

Tuning ASGI_THREADS and manual cleanup

If you have committed to AsyncWebsocketConsumer with database_sync_to_async(thread_sensitive=False), you have two levers: the thread pool size and manual connection management.

Controlling the thread pool size
# Tuning the thread pool for database connection control

# Method 1: Set ASGI_THREADS environment variable
# This controls the ThreadPoolExecutor size used by database_sync_to_async
# Lower = fewer database connections, but also fewer concurrent ORM calls

# In your deployment configuration:
ASGI_THREADS=10  # instead of default 40

# Method 2: Configure in asgi.py
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")

# Set thread pool size before importing anything else
import asyncio
from concurrent.futures import ThreadPoolExecutor

# Limit to 10 threads = at most 10 concurrent database connections
# from database_sync_to_async calls
loop = asyncio.get_event_loop()
loop.set_default_executor(ThreadPoolExecutor(max_workers=10))

from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from myapp.routing import websocket_urlpatterns

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": AuthMiddlewareStack(
        URLRouter(websocket_urlpatterns)
    ),
})

# The constraint: with 10 threads and 200 connected WebSocket users,
# only 10 ORM operations can run concurrently.
# If each ORM call takes 5ms, throughput = 10 / 0.005 = 2,000 ops/second.
# For most chat/notification apps, that is plenty.
# For high-frequency data feeds with ORM calls on every message, it is not.

Reducing ASGI_THREADS from 40 to 10 caps your maximum concurrent database connections at 10 per ASGI process. With two Daphne or uvicorn workers, that is 20 connections total. This fits comfortably within max_connections = 100 and leaves room for HTTP traffic and background workers.

The throughput math: 10 threads with 5ms average query time gives 2,000 database operations per second. For most real-time applications — chat messages, notification dispatches, presence updates — that is more than sufficient. If your consumers query the database on every message and you handle 5,000 messages per second, 10 threads will not keep up. But at that volume, you have a design problem, not a tuning problem. An application processing 5,000 WebSocket messages per second, each requiring a database write, should be using batched inserts or a write-ahead queue, not individual ORM calls.

A word of caution on setting the thread pool in asgi.py: the asyncio.get_event_loop() call creates a new event loop if one does not exist. In some deployment configurations — particularly with uvicorn using uvloop — this can create a loop that is not the one the server actually uses. The safer approach for uvicorn is the ASGI_THREADS environment variable, which is read by the Channels library itself. For Daphne, which uses Twisted's reactor, the thread pool configuration is different again.

The second lever is manual connection cleanup:

Explicit connection management in consumers
# Manual connection management in consumers — the disciplined approach

from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.db import close_old_connections, connection
import json

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
        await self.accept()

    async def receive(self, text_data):
        data = json.loads(text_data)
        message = await self._save_message_with_cleanup(data["message"])
        await self.send(text_data=json.dumps({
            "message": message["content"],
            "user": message["user"],
        }))

    @database_sync_to_async(thread_sensitive=False)
    def _save_message_with_cleanup(self, content):
        try:
            # Close stale connections before doing work
            close_old_connections()

            msg = ChatMessage.objects.create(
                room=self.room_name,
                user=self.scope["user"],
                content=content,
            )
            return {"content": msg.content, "user": str(msg.user)}
        finally:
            # Explicitly close the connection after work is done
            connection.close()

    async def disconnect(self, close_code):
        # Also clean up on disconnect
        await database_sync_to_async(close_old_connections)()

# This works, but it is relentless bookkeeping.
# Every database_sync_to_async call needs the try/finally pattern.
# Miss one, and connections leak.
# And you lose connection reuse — every ORM call opens a fresh connection.
# That is 3-5ms of connection setup overhead per database operation.
# At 100 messages/second, you are spending 300-500ms/second on TCP handshakes.

Calling connection.close() after every ORM operation ensures the connection is returned immediately instead of lingering in the thread-local storage. Combined with close_old_connections() before each operation (which Django calls automatically in its request/response cycle but not in Channels consumers), this prevents accumulation.

The cost is double. First, the bookkeeping: every database_sync_to_async call needs the try/finally pattern. Miss one and connections leak. In a codebase with twenty consumers and sixty ORM-accessing methods, that is sixty try/finally blocks that must be correct, and a single omission reintroduces the leak. Code review catches most of these. Production catches the rest.

Second, the performance: you lose connection reuse. Each ORM call opens a fresh connection (3-5ms of TCP handshake plus TLS negotiation plus PostgreSQL authentication) and closes it afterward. At 100 database operations per second, that is 300-500ms per second spent on connection overhead — not catastrophic, but not free. If your PostgreSQL server is across a network boundary rather than on localhost, the per-connection overhead rises to 10-20ms (network round trips for TCP and TLS), and the tax becomes more noticeable.

There is a way to reduce the bookkeeping burden — a decorator that centralizes the cleanup logic:

A centralized cleanup decorator
# A middleware approach: wrapping every database_sync_to_async call
# with connection cleanup, so you don't have to remember.

from functools import wraps
from channels.db import database_sync_to_async
from django.db import close_old_connections, connection

def db_async(thread_sensitive=False):
    """Decorator that wraps a sync function for async use,
    with automatic connection cleanup before and after."""
    def decorator(func):
        @database_sync_to_async(thread_sensitive=thread_sensitive)
        @wraps(func)
        def wrapper(*args, **kwargs):
            close_old_connections()
            try:
                return func(*args, **kwargs)
            finally:
                connection.close()
        return wrapper
    return decorator

# Usage in consumers:
class ChatConsumer(AsyncWebsocketConsumer):

    @db_async(thread_sensitive=False)
    def _save_message(self, content):
        # No try/finally needed — the decorator handles it
        msg = ChatMessage.objects.create(
            room=self.room_name,
            user=self.scope["user"],
            content=content,
        )
        return {"content": msg.content, "user": str(msg.user)}

    @db_async(thread_sensitive=False)
    def _get_recent_messages(self):
        return list(
            ChatMessage.objects.filter(room=self.room_name)
            .order_by("-created_at")[:50]
            .values("content", "user__username", "created_at")
        )

# This is better. The cleanup is centralized.
# But you still pay the per-call connection cost.
# And the decorator is your code — you must maintain it,
# document it, and ensure every developer on the team uses it
# instead of raw database_sync_to_async.

This is a meaningful improvement. The cleanup is in one place, not sixty. But it introduces its own fragility: every developer on the team must use @db_async instead of @database_sync_to_async. A new team member who follows the official Django Channels documentation — which uses database_sync_to_async directly — will bypass the cleanup decorator. You need documentation, linting rules, or code review conventions to enforce the pattern. This is manageable. It is also one more thing to manage.

I should be honest about the limits of this approach: you are fighting Django's design assumptions with manual discipline. It works. Teams do it successfully. But it is defensive coding against a framework that is working exactly as designed — just not designed for your use case. Every new Channels feature requires remembering the cleanup ritual, and every forgotten cleanup is a slow connection leak that only manifests under sustained production load.

Daphne, Uvicorn, and Hypercorn: does the ASGI server matter?

A question that arises frequently: does switching ASGI servers change the connection dynamics? The short answer is no. The longer answer is instructive.

ASGI server connection behavior comparison
# Connection behavior differences: Daphne vs Uvicorn

# --- Daphne (the original Django Channels ASGI server) ---
# Process model: single-process by default, multi-process via daphne -p
# Thread allocation: one thread per SyncConsumer (no shared pool)
# Event loop: Twisted's reactor
#
# daphne myproject.asgi:application --port 8000
# daphne myproject.asgi:application --port 8000 -p 4  # 4 worker processes
#
# Key behavior:
# - SyncConsumer: 1 thread per WebSocket. No thread pool. No limit.
#   200 WebSockets = 200 threads = 200 connections.
# - AsyncConsumer: event loop handles WebSocket I/O.
#   database_sync_to_async uses the default executor.
#   Thread pool size = ASGI_THREADS env var or Python default.

# --- Uvicorn (the fast ASGI server) ---
# Process model: single worker by default, multi-worker via --workers
# Thread allocation: configurable via --limit-max-incomplete-event-size
# Event loop: uvloop (if available) or asyncio
#
# uvicorn myproject.asgi:application --host 0.0.0.0 --port 8000
# uvicorn myproject.asgi:application --workers 4 --host 0.0.0.0 --port 8000
#
# Key behavior:
# - Handles WebSocket connections on the event loop (same as Daphne)
# - database_sync_to_async thread pool controlled by ASGI_THREADS
# - uvloop is faster for WebSocket I/O, but does not change
#   the database connection dynamics — those are Django-side

# --- Hypercorn ---
# Process model: single worker or multi-worker
# Supports HTTP/2 and HTTP/3
# Thread pool: same ASGI_THREADS mechanism
#
# hypercorn myproject.asgi:application --bind 0.0.0.0:8000 --workers 4
#
# All three servers produce the SAME database connection pattern.
# The ASGI server affects WebSocket I/O performance.
# The database connection problem is in Django, not in the server.

The database connection problem lives in Django, not in the ASGI server. Daphne, Uvicorn, and Hypercorn all produce the same connection pattern because they all run the same Django code, using the same database_sync_to_async mechanism, with the same thread pool, accessing the same per-thread connection storage in Django's database backend.

What the ASGI server does affect is WebSocket I/O performance. Uvicorn with uvloop handles WebSocket frames faster than Daphne with Twisted's reactor. Hypercorn adds HTTP/2 and HTTP/3 support. If your bottleneck is WebSocket throughput — frames per second, message latency, connection establishment time — the choice of ASGI server matters considerably.

If your bottleneck is database connection exhaustion, switching ASGI servers changes nothing. I mention this because it is a common false lead. Teams will spend a day migrating from Daphne to Uvicorn, observe that WebSocket performance improves, assume the connection problem is also fixed, and discover two weeks later that the same idle connections are still accumulating. The WebSocket layer and the database layer are independent concerns, and improving one does not address the other.

The one ASGI-server-level consideration that does affect connections is the process model. Daphne with -p 4 runs four worker processes. Uvicorn with --workers 4 does the same. Each process gets its own thread pool, its own Django connection storage, and its own set of database connections. Doubling your worker count doubles your maximum database connections. This is the same cross-process problem that Django 5.1's connection pool does not solve.

Django 5.1 connection pooling: progress, not panacea

Django 5.1, released in August 2024, introduced built-in connection pooling via psycopg3's ConnectionPool. This is the most significant improvement to Django's database connection handling in years. It deserves genuine appreciation. It is also not the complete answer for Channels.

Django 5.1 connection pool configuration
# Django 5.1+ connection pooling — the partial fix

# settings.py (Django 5.1+)
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "myapp",
        "HOST": "localhost",
        "PORT": "5432",
        "USER": "appuser",
        "PASSWORD": "secret",
        "CONN_MAX_AGE": 600,
        "CONN_HEALTH_CHECKS": True,
        "OPTIONS": {
            "pool": {
                "min_size": 2,
                "max_size": 20,  # maximum connections in the pool
                # Django 5.1 uses psycopg3's ConnectionPool internally
            },
        },
    }
}

# What Django 5.1 connection pooling gives you:
# - Thread-safe connection pool (replaces per-thread connection storage)
# - Connection reuse across threads (finally)
# - Health checking before checkout
# - Max size enforcement

# What it does NOT give you for WebSocket consumers:
# - The pool is still inside the Django process
# - Multiple ASGI workers = multiple pools = multiple x max_size
# - 4 Daphne workers x max_size=20 = up to 80 connections
# - No cross-process connection sharing
# - No multiplexing — a checked-out connection is held until returned
#
# For HTTP: excellent improvement. Pool reuses connections across requests.
# For WebSocket: helps with database_sync_to_async threads,
#   but does not solve the fundamental problem of long-lived consumers
#   accumulating connections over time.

The pooling replaces Django's per-thread connection storage with a shared, thread-safe connection pool. When a thread needs a database connection, it checks one out from the pool. When it finishes, the connection returns to the pool. Connections are reused across threads. This is how connection pools work in every other framework, and it is a welcome addition — one that Django users have been requesting for the better part of a decade.

For HTTP request handling, the improvement is substantial. 40 concurrent requests no longer require 40 database connections. The pool provides and recycles connections as needed, holding a maximum of max_size open to PostgreSQL. If you are running Django without Channels, this feature alone may eliminate any connection concerns you had.

For WebSocket consumers, the improvement is partial. Here is the nuanced configuration:

Django 5.1 pool tuned for Channels
# Django 5.1 pool + Channels: what the configuration actually looks like

# settings.py
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "myapp",
        "HOST": "localhost",
        "PORT": "5432",
        "USER": "appuser",
        "PASSWORD": "secret",
        "CONN_MAX_AGE": None,        # persistent connections — pool manages lifecycle
        "CONN_HEALTH_CHECKS": True,
        "OPTIONS": {
            "pool": {
                "min_size": 2,
                "max_size": 10,      # tight limit per process
                "timeout": 5,        # seconds to wait for a free connection
                "max_idle": 300,     # close idle connections after 5 minutes
            },
        },
    }
}

# With this configuration and AsyncWebsocketConsumer:
#
# - database_sync_to_async calls check out a connection from the pool
# - When the sync function returns, the connection goes back to the pool
# - No manual connection.close() needed
# - max_size=10 means at most 10 connections per ASGI process
#
# The catch: this only works if your sync functions are SHORT.
# A sync function that does:
#   1. Query the database (holds connection)
#   2. Call an external API (still holding connection)
#   3. Query the database again
# holds the connection for the entire duration of step 2.
# The pool connection is occupied but the database is idle.
#
# Rule of thumb: keep database_sync_to_async functions
# focused on database work only. Do everything else in async land.

# The multi-process problem remains:
# 4 uvicorn workers x max_size=10 = 40 connections to PostgreSQL
# That is much better than 200, but still scales linearly with workers.

With database_sync_to_async(thread_sensitive=False) and the connection pool, the per-call connection overhead disappears. The thread checks out a connection, uses it, returns it — no TCP handshake on every call. This is a genuine improvement over the manual connection.close() approach, which sacrificed connection reuse for cleanup certainty. With the pool, you get both reuse and bounded connection count.

But the pool's max_size is per Django process. Four ASGI worker processes with max_size=10 means up to 40 connections to PostgreSQL. Scale to 8 workers during a traffic spike — which is what auto-scaling does — and you are at 80 connections. Each scaling event requires recalculating the pool size against max_connections. The arithmetic is simpler than before (fewer moving parts), but it is still arithmetic you must do correctly, and you must redo it every time your deployment topology changes.

There is another subtlety. If a database_sync_to_async function does more than just database work — say it queries the database, then calls an external API, then queries again — it holds a checked-out connection for the entire duration. The connection is occupied but idle while the external API call runs. In a pool of 10, that is 10% of your capacity held hostage by a network call that has nothing to do with the database. The rule of thumb: keep your database_sync_to_async functions focused exclusively on database operations. Do everything else in async code, which does not hold pool connections.

I should also note that Django 5.1's pooling requires psycopg3 (the psycopg package), not psycopg2. If your application uses psycopg2-binary or django.db.backends.postgresql with psycopg2 under the hood, you will need to migrate to psycopg3 first. This is a worthwhile migration regardless — psycopg3 is the actively maintained driver with native async support — but it is not zero-effort, particularly if you have raw SQL that relies on psycopg2-specific APIs like RealDictCursor or register_adapter.

The pool also does not solve the cross-process problem. If you scale from 2 ASGI workers to 8, your maximum connections quadruple. This is a fundamental limitation of in-process connection pooling. The pool is excellent at managing connections within a single process. It has no visibility into — and no control over — what other processes are doing. For that, you need an external connection pooler.

"Serverless functions create and destroy connections with an enthusiasm that would alarm any connection pooler designed for long-lived application servers. WebSocket consumers, in their way, present the inverse problem — they hold connections for far longer than anyone designed for."

— from You Don't Need Redis, Chapter 15: The Serverless Connection Crisis

PgBouncer: the external pooler option

Before Gold Lapel existed, the standard answer to "my application has too many database connections" was PgBouncer. It remains a viable option, and I would be doing you a disservice not to cover it thoroughly.

PgBouncer configuration for Django Channels
# PgBouncer configuration for Django Channels
# /etc/pgbouncer/pgbouncer.ini

[databases]
myapp = host=127.0.0.1 port=5432 dbname=myapp

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

# Transaction pooling — connections are shared between transactions
pool_mode = transaction

# Maximum connections to PostgreSQL
default_pool_size = 20
max_db_connections = 25

# Maximum client connections (your Django app)
max_client_connections = 300

# How long to keep unused server connections alive
server_idle_timeout = 30

# Django settings.py
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "myapp",
        "HOST": "127.0.0.1",
        "PORT": "6432",           # PgBouncer port
        "USER": "appuser",
        "PASSWORD": "secret",
        "CONN_MAX_AGE": 0,        # close Django-side after each use
        # CRITICAL: Disable server-side prepared statements.
        # Transaction-mode PgBouncer cannot route them correctly.
        "OPTIONS": {
            "options": "-c default_transaction_isolation=read\ committed",
        },
        "DISABLE_SERVER_SIDE_CURSORS": True,
    }
}

# PgBouncer in transaction mode:
# - 200 WebSocket consumers can open 200 connections to PgBouncer
# - PgBouncer holds 20 connections to PostgreSQL
# - When a consumer runs a transaction, it gets a server connection
# - When the transaction completes, the server connection returns to the pool
# - Between transactions, the consumer holds only a PgBouncer-side connection
#
# The limitation: transaction-mode pooling breaks features that
# depend on session state — SET commands, advisory locks, LISTEN/NOTIFY,
# prepared statements, temporary tables, session variables.
# Django uses some of these. You must disable server-side cursors
# and cannot use features that set session-level state.

PgBouncer sits between your application and PostgreSQL. Your Django application connects to PgBouncer on port 6432. PgBouncer maintains a pool of connections to PostgreSQL and multiplexes your application's connections onto them. 200 client connections from your WebSocket consumers map to 20 server connections to PostgreSQL. The math works.

The configuration matters enormously, and the most critical choice is pool_mode. PgBouncer offers three modes:

  • Session mode: one server connection per client connection, held for the client's session. This does not help you — it is essentially a pass-through. 200 WebSocket consumers still use 200 server connections.
  • Transaction mode: a server connection is assigned per transaction and returned when the transaction completes. Between transactions, the client holds only a PgBouncer-side connection. This is the mode that helps with Channels.
  • Statement mode: a server connection is assigned per SQL statement. This is too aggressive for Django, which uses multi-statement transactions.

Transaction mode is the correct choice for Django Channels, and it works. Your WebSocket consumers hold PgBouncer-side connections indefinitely, but PostgreSQL connections are only occupied during actual database transactions. Between messages — which is most of the time for a chat consumer that processes one message every few seconds — no PostgreSQL connection is held.

The honest counterpoint: transaction-mode PgBouncer breaks several PostgreSQL features that depend on session state. Prepared statements, SET commands, advisory locks, LISTEN/NOTIFY, temporary tables, and session-level variables all rely on the client maintaining a consistent session with a specific server connection. Transaction-mode pooling breaks that assumption by giving you a different server connection for each transaction.

For Django specifically, this means:

  • You must set DISABLE_SERVER_SIDE_CURSORS = True in your database settings. Django uses server-side cursors for iterator() calls, and these are session-state features.
  • You cannot use LISTEN/NOTIFY through PgBouncer in transaction mode. If your Channels application uses PostgreSQL as a channel layer backend (via channels_postgres), this is a problem.
  • Any Django middleware or package that uses SET commands (e.g., setting search_path per tenant in multi-tenant applications) will not work reliably.
  • The psycopg3 driver's prepared statement handling can conflict with transaction-mode pooling, though Django's ORM typically does not use prepared statements by default.

PgBouncer is well-tested, widely deployed, and solves the connection multiplexing problem. It is also an additional service to deploy, configure, monitor, and maintain. Its configuration file is not complex, but its pool sizing requires understanding your workload. Undersizing the pool causes queuing under load. Oversizing defeats the purpose. And the transaction-mode limitations require testing your application's full feature set to ensure nothing relies on session state.

The Channel Layer pattern: zero database connections in consumers

There is an architectural approach that sidesteps the connection problem entirely: remove all database access from your WebSocket consumers.

WebSocket consumer with no database access
# The Channel Layer pattern — decoupling WebSocket from database access

# consumers.py — WebSocket consumer does NO direct database access
from channels.generic.websocket import AsyncWebsocketConsumer
import json

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
        self.room_group = f"chat_{self.room_name}"

        # Join the channel layer group
        await self.channel_layer.group_add(self.room_group, self.channel_name)
        await self.accept()

    async def receive(self, text_data):
        data = json.loads(text_data)

        # Send to channel layer — NOT to the database
        await self.channel_layer.group_send(
            self.room_group,
            {
                "type": "chat.message",
                "message": data["message"],
                "user": str(self.scope["user"]),
            }
        )

    async def chat_message(self, event):
        # Receive from channel layer, forward to WebSocket client
        await self.send(text_data=json.dumps({
            "message": event["message"],
            "user": event["user"],
        }))

    async def disconnect(self, close_code):
        await self.channel_layer.group_discard(self.room_group, self.channel_name)

# A separate Celery worker or Django management command handles persistence:
# @shared_task
# def persist_chat_message(room, user, content):
#     ChatMessage.objects.create(room=room, user=user, content=content)
#
# The WebSocket consumer touches zero database connections.
# The Celery worker uses its own small, controlled connection pool.
# Clean separation. But added complexity and eventual consistency.

The idea: your WebSocket consumer handles only WebSocket I/O and channel layer messaging. No ORM calls. No database connections. When a message needs to be persisted, the consumer dispatches it to a Celery task, a Django management command, or another service that has its own controlled database access.

This pattern is architecturally sound. The WebSocket layer scales independently from the database layer. You can run 1,000 concurrent WebSocket connections without touching max_connections. The channel layer (typically Redis) handles the pub/sub fanout. Database writes happen in workers with small, predictable connection pools.

I have seen this pattern work extremely well in production, particularly for applications where the WebSocket traffic is primarily broadcast — a live scoreboard, a stock ticker, a notifications feed. The data flows one direction (server to client) or is fan-out heavy (one message to many clients), and the persistence requirement is secondary to the delivery requirement.

The trade-off: complexity and consistency. You now have two systems — the WebSocket layer and the persistence layer — that must stay synchronized. Messages might be delivered to WebSocket clients before they are persisted to the database. If a user sends a chat message and then immediately refreshes the page, the HTTP view might not find the message in the database yet because the Celery worker has not processed it. This is eventual consistency, and while it is a well-understood pattern, it requires explicit handling in your application.

For chat applications, this gap is usually acceptable — a 100ms delay between WebSocket delivery and database persistence is invisible to users. For financial transactions, order processing, or collaborative editing with conflict resolution, the gap requires careful design. You need idempotency. You need ordering guarantees. You may need a write-ahead log pattern where the client does not consider the message "sent" until the persistence layer acknowledges it.

There is also the Redis dependency. The channel layer requires Redis (or another compatible backend like channels_redis). Redis is another service to operate, monitor, and scale. If Redis goes down, your WebSocket consumers cannot communicate with each other or with the persistence layer. You have traded a database connection problem for a Redis availability dependency. This is often a good trade — Redis is simpler to operate and scale than PostgreSQL for this use case — but it is a trade, not an elimination of operational burden.

The Channel Layer pattern also changes your testing and debugging story. When a user reports "my message disappeared," you now have three places to investigate: the WebSocket consumer (did it dispatch to the channel layer?), the channel layer itself (did Redis deliver to the worker?), and the persistence worker (did it write to the database?). Each boundary is a potential failure point. Distributed systems debugging is not harder than monolithic debugging, but it is different, and it requires different tooling — request tracing, message queue monitoring, dead letter queues.

The complete comparison

Every approach, side by side. The "Max with 200 users" column shows the maximum simultaneous PostgreSQL connections consumed when 200 WebSocket clients are connected.

ApproachConnection behaviorConnection lifetimeMax with 200 usersComplexity
SyncConsumer (default)1 per WebSocketWebSocket duration200Low
AsyncConsumer + database_sync_to_async1 per thread pool threadPer ORM call (if cleaned up)10-40 (pool size)Medium
Manual connection.close()1 per ORM call (no reuse)Single operation10-40 (pool size)High
Django 5.1 connection poolUp to pool max_sizeManaged by pool20 (per process)Medium
Channel Layer (no DB in consumer)0 in consumerN/A0 + worker connsHigh
PgBouncer (transaction mode)App-side: unlimitedPer transaction20 to PostgreSQLMedium-High
Gold Lapel proxyApp-side: unlimitedManaged by GL20 to PostgreSQLLow

Four observations from this table.

SyncConsumer is untenable at any meaningful scale. One connection per WebSocket, held for the WebSocket's lifetime, is incompatible with PostgreSQL's connection model. If you are using WebsocketConsumer (the synchronous variant) with any ORM calls, switch to AsyncWebsocketConsumer with database_sync_to_async immediately. This is not a performance optimization; it is a correctness fix.

The thread pool approaches (rows 2-4) all trade connection count for throughput. You can limit connections by limiting threads or pool size, but you cap your concurrent database throughput in the process. This is the right trade-off for most applications — real-time features rarely need hundreds of concurrent database operations — but it requires understanding your workload. If you do not measure your actual concurrent ORM call volume, you are guessing at the thread pool size, and guessing produces either wasted connections or queued requests.

PgBouncer, the Channel Layer pattern, and Gold Lapel all achieve near-zero or zero consumer-held PostgreSQL connections, through different means. PgBouncer multiplexes at the transaction level, with session-state limitations. The Channel Layer avoids the database entirely in the consumer layer, adding architectural complexity and a Redis dependency. Gold Lapel multiplexes at the session level, preserving full PostgreSQL feature compatibility. All three are valid. The right choice depends on your constraints.

Complexity is the hidden cost in every row. SyncConsumer is simple to write but impossible to scale. Manual connection.close() scales but is fragile. The Channel Layer scales beautifully but is architecturally complex. The approaches that solve the connection problem most completely (Channel Layer, external pooler, Gold Lapel) all add either application complexity or infrastructure components. There is no free lunch. The question is which cost you prefer to pay.

A decision framework: which approach to choose

If you will permit me, I would like to offer guidance beyond the comparison table. The right choice depends on your specific situation, and there are patterns I have observed that may help you decide.

If you have fewer than 50 concurrent WebSocket users and a straightforward use case (chat, notifications), the thread pool approach with AsyncWebsocketConsumer and ASGI_THREADS=10 is likely sufficient. Use the @db_async decorator pattern to centralize connection cleanup. Monitor your connection count in PostgreSQL. This approach has the lowest operational overhead and keeps your application architecture simple.

If you have 50-500 concurrent WebSocket users and your WebSocket feature is important to the product, invest in either Django 5.1's connection pool (if you are on Django 5.1+ with psycopg3) or an external connection pooler. The in-process pool is simpler to deploy but scales with your worker count. PgBouncer or Gold Lapel adds an infrastructure component but provides cross-process connection sharing and hard limits on PostgreSQL connection usage.

If you have 500+ concurrent WebSocket users or your WebSocket workload is bursty (e.g., a live event that spikes from 50 to 2,000 connections in minutes), an external solution is not optional — it is required. In-process pooling cannot absorb that kind of spike without either queuing requests or exceeding max_connections. PgBouncer or Gold Lapel provides the absorption layer you need.

If your WebSocket consumers do not need database access at all — pure broadcast, notification delivery, presence tracking — the Channel Layer pattern is the cleanest approach. Zero database connections in the consumer layer is the best number of database connections in the consumer layer. But do not adopt this pattern just to avoid the connection problem if your consumers genuinely need database access. Architecturing around a problem is sometimes elegant and sometimes a detour.

An honest admission: most teams discover the connection problem before they have time to evaluate all the options. They are in production, users are affected, and they need a fix by Monday. If that is your situation, the fastest remediation is to reduce ASGI_THREADS to a safe number (10 is usually fine) and add the cleanup decorator. That buys you time to evaluate the longer-term architectural options without the urgency of a production incident.

Monitoring: catching the problem before it catches you

Whatever approach you choose, monitoring your connection count is non-negotiable. The connection exhaustion problem is progressive — connections accumulate gradually, and the failure is sudden. You do not get a graceful degradation. You get a cliff: one moment everything works, the next moment every new connection attempt fails.

Monitoring queries for connection health
# Monitoring queries for ongoing connection health.
# Run these periodically or wire them into your alerting.

-- Connection count by state, refreshed every 30 seconds via pg_stat_activity
SELECT
    state,
    count(*) AS connections,
    round(100.0 * count(*) / (SELECT setting::int FROM pg_settings WHERE name = 'max_connections'), 1) AS pct_of_max
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state
ORDER BY connections DESC;

-- Connections that have been idle for more than 10 minutes
-- These are almost certainly held by WebSocket consumer threads
SELECT
    pid,
    usename,
    now() - state_change AS idle_time,
    now() - backend_start AS total_age
FROM pg_stat_activity
WHERE state = 'idle'
  AND backend_type = 'client backend'
  AND now() - state_change > interval '10 minutes'
ORDER BY state_change ASC;

-- Connection churn rate over the last hour
-- High churn with high count = connections opening but not closing
SELECT
    count(*) FILTER (WHERE backend_start > now() - interval '1 hour') AS new_connections_last_hour,
    count(*) FILTER (WHERE backend_start > now() - interval '5 minutes') AS new_connections_last_5min,
    count(*) AS total_connections
FROM pg_stat_activity
WHERE backend_type = 'client backend';

-- Alert threshold: fire when connections exceed 80% of max
-- In your monitoring tool (Prometheus, Datadog, etc.):
-- pg_stat_activity_count / pg_settings_max_connections > 0.8

The key metrics to track:

  • Total connections as a percentage of max_connections. Alert at 80%. Investigate at 60%. If you are routinely above 50% during normal traffic, your headroom for traffic spikes is inadequate.
  • Idle connection count. A high idle count relative to active count indicates connections being held unnecessarily. If you see 140 idle and 3 active, those 140 connections are held by consumers or threads that are not doing database work.
  • Connection age distribution. In a healthy HTTP application, most connections are young — seconds or minutes old. If your oldest connections are hours old, you have long-lived threads holding connections. This is the signature of the Channels connection problem.
  • Connection churn. If you are using the manual connection.close() approach, you should see high churn — connections opening and closing frequently. If the total count is still rising despite high churn, you have a leak somewhere. Probably a database_sync_to_async call that bypasses the cleanup decorator.

In your monitoring stack — Prometheus with postgres_exporter, Datadog, New Relic, or even a simple cron job running the queries above — set up an alert for when connections exceed 80% of max_connections. Give yourself enough warning to act before you hit the wall. I have seen teams operate for months with connection usage at 85-95%, skating by on the margin between their peak traffic and max_connections, until a marketing campaign or product launch pushes them over. The alert gives you time. Without it, you learn about the problem from your users.

What Gold Lapel does for Django Channels

I have been thorough about the application-level fixes because they are what you can do today, with no new infrastructure. But I would be remiss not to explain the infrastructure-level solution, since it eliminates the problem rather than managing it.

Django settings with Gold Lapel
# Gold Lapel: the connection math changes entirely

# settings.py — pip install goldlapel-django, one-line ENGINE swap
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "myapp",
        "HOST": "localhost",
        "PORT": "5433",          # Gold Lapel's port
        "USER": "appuser",
        "PASSWORD": "secret",
        "CONN_MAX_AGE": 0,       # let GL handle connection lifecycle
    }
}

# The math with Gold Lapel:
#
# Before (direct to PostgreSQL):
#   200 WebSocket consumers (SyncConsumer threads)  = 200 connections
#   20 HTTP request threads                         = 20 connections
#   8 Celery worker threads                         = 8 connections
#                                             Total = 228 connections
#   PostgreSQL max_connections = 100  --> FATAL
#
# After (through Gold Lapel):
#   200 WebSocket consumer connections --> Gold Lapel
#   20 HTTP connections                --> Gold Lapel
#   8 Celery connections               --> Gold Lapel
#                                      = 228 connections TO Gold Lapel
#                                      = 20 connections FROM Gold Lapel to PostgreSQL
#
#   228 app connections multiplexed onto 20 PostgreSQL connections.
#   No max_connections crisis. No connection arithmetic.
#   No ASGI_THREADS tuning. No manual close_old_connections().
#
# WebSocket consumers can hold connections for hours.
# Gold Lapel holds the backend connections efficiently.
# Your app connects freely. GL manages the scarcity.

Gold Lapel is a PostgreSQL proxy with session-mode connection multiplexing built in. Your Django application — HTTP views, WebSocket consumers, Celery workers — connects to Gold Lapel on port 5433. Gold Lapel maintains a pool of 20 backend connections to PostgreSQL (configurable) and multiplexes all your application connections onto them.

The critical advantage for Channels: your WebSocket consumers can hold connections for hours without consuming PostgreSQL connection slots. A SyncConsumer thread that holds a connection for the duration of a four-hour WebSocket session is holding a connection to Gold Lapel, not to PostgreSQL. Gold Lapel's backend connections are shared, managed, and recycled independently of your consumer lifecycle.

Unlike PgBouncer in transaction mode, Gold Lapel operates in session mode — which means it preserves session state. Prepared statements, SET commands, advisory locks, LISTEN/NOTIFY, temporary tables — all work as expected. You do not need to set DISABLE_SERVER_SIDE_CURSORS. You do not need to test your entire application for session-state dependencies. Your Django code does not change at all beyond the port number in your settings.

This means you can use the simpler consumer patterns — even SyncConsumer with direct ORM access — without worrying about connection exhaustion. You do not need database_sync_to_async wrappers for connection management reasons (though you may still want AsyncWebsocketConsumer for concurrency reasons — a single event loop handles thousands of WebSocket connections more efficiently than a thread per connection). You do not need manual connection.close() calls. You do not need the cleanup decorator. You do not need to tune ASGI_THREADS for connection budget purposes.

228 application connections multiplexed onto 20 PostgreSQL connections. No max_connections arithmetic. No connection cleanup bookkeeping. No session-state limitations. Install the package, swap the ENGINE in your settings. The rest of your Django code stays exactly as it is.

I should be forthcoming about the trade-off: Gold Lapel is an additional process in your stack. It adds a network hop between your application and PostgreSQL (typically sub-millisecond on the same host, but measurable). It needs to be deployed, monitored, and kept running. For teams that are already operating PgBouncer or another connection pooler, the operational burden is familiar. For teams that have never run a database proxy, it is a new operational concern. Whether that concern is justified depends on how much time you are currently spending on connection management in your application code — and, if you are reading this article, the answer is probably "more than you would like."

The connection never truly closes. It just finds a better home.

If you have followed me this far — through the thread model, the connection lifecycle, the CONN_MAX_AGE dead end, the database_sync_to_async trade-offs, the thread pool tuning, the pool configuration, the external pooler options, and the monitoring setup — you now understand this problem more thoroughly than most engineers who encounter it. That understanding is worth something, regardless of which solution you choose.

The fundamental tension is simple: WebSocket connections are long-lived, and PostgreSQL connections are expensive. Everything we have discussed is a strategy for managing the mismatch between those two facts. You can limit the connections (thread pool tuning), you can manage their lifecycle manually (cleanup decorators), you can pool them in-process (Django 5.1), you can pool them externally (PgBouncer, Gold Lapel), or you can avoid the database entirely in the WebSocket layer (Channel Layer pattern).

Each approach has a cost. The cheapest approaches cost you in operational fragility — one missed cleanup, one miscalculated pool size, and connections leak. The most complete approaches cost you in infrastructure complexity — another service to run, another configuration to maintain. The question is not "which approach has no cost?" but "which cost am I best equipped to manage?"

If I may offer a closing recommendation: whatever you choose, monitor your connection count. The pg_stat_activity queries we reviewed are your early warning system. Connection exhaustion is a problem that announces itself in the metrics long before it announces itself in the error logs. Listen to the metrics.

Your WebSocket feature is worth building. Real-time interaction is what makes web applications feel alive. The connection management problem is a solvable obstacle, not a reason to retreat to polling. You simply need to account for the fact that a long-lived connection and a long-lived database connection are not, and should not be, the same thing.

The household can accommodate any number of guests. It simply cannot give each one their own private wing indefinitely. A good waiter knows this, and arranges the rooms accordingly.

Frequently asked questions

Terms referenced in this article

The max_connections ceiling that WebSocket connections press against is a broader concern than Django Channels alone. I have written a thorough examination of PostgreSQL's max_connections — what each connection actually costs in memory, the shared buffer implications, and why the default of 100 is both too many and too few.