Chirp — A Web Framework Built for Free-Threaded Python

How Chirp achieves thread-safe request handling with ContextVar isolation, frozen config, and double-check locking — patterns for building web frameworks on free-threaded Python.

Chirp is built around a simple requirement: many threads should be able to hit the same app instance without the framework quietly depending on the GIL for safety.

Picture four threads hitting the same Chirp app instance at once. One router. One Kida environment. One middleware stack.

If anything in that shared runtime mutates during a request, you get a race. The framework is designed so that request handling happens against frozen or request-local state instead.


Series context

Part 6 of 6Free-Threading in the Bengal Ecosystem. This is the user-facing layer — what it's like to build apps on a free-threading-ready stack.


What it looks like

from chirp import App, AppConfig

app = App(AppConfig())

@app.route("/")
def index():
    return "Hello, World!"

app.run()

Or with Pounce for production:

uv run --python=3.14t pounce myapp:app --workers 4

On Python 3.14t, four worker threads share this single Chirp app instance. The rest of this post is really about one question: how do you make that safe without filling the framework with incidental locking?


Mutable setup, frozen runtime

The app is mutable during setup: decorators, add_middleware(), mount_pages(). At runtime, when the first request arrives, it freezes. Routes, middleware, and the Kida template environment become immutable. No more @app.route() after that.

Under free-threading, multiple worker threads can call __call__() concurrently on the first request. Chirp uses double-check locking so exactly one thread performs the freeze:

def _ensure_frozen(self) -> None:
    if self._frozen:
        return                    # Fast path — no lock
    with self._freeze_lock:
        if self._frozen:
            return                # Another thread beat us
        self._freeze()            # Compile routes, freeze config

The fast path avoids lock acquisition on every request. The lock ensures exactly one thread runs _freeze().

That gives Chirp a clean split between two phases: a mutable setup phase and an immutable serving phase.


ContextVar for request-scoped state

Request, session, and g (the request-scoped namespace) live in ContextVar:

request_var: ContextVar[Request] = ContextVar("chirp_request")

_session_var: ContextVar[dict[str, Any] | None] = ContextVar(
    "chirp_session", default=None
)

ContextVar is task-local under asyncio and thread-local under free-threading. Each request gets its own context, so there is no shared mutable request state between concurrent requests.

For the app developer, the important part is that the ergonomics stay familiar. You still use request, session, and g; the isolation model just stops depending on thread-local hacks that become fragile in modern concurrency setups.


Per-worker lifecycle for async resources

httpx clients, DB connection pools, and other async resources bind to an event loop. Under Pounce's thread workers, each worker has its own loop. Chirp provides per-worker hooks for exactly that reason:

_client_var: ContextVar[httpx.AsyncClient | None] = ContextVar(
    "hn_client", default=None
)

@app.on_worker_startup
async def create_client():
    _client_var.set(httpx.AsyncClient(timeout=10.0))

@app.on_worker_shutdown
async def close_client():
    client = _client_var.get(None)
    if client:
        await client.aclose()

One client per worker, created on startup and cleaned up on shutdown. No cross-thread reuse, and no hidden global client that becomes a contention point later.


Lock for shared mutable state

When app code really does need shared mutable state, such as caches, counters, or in-memory stores, the pattern is explicit:

_readings: dict[str, SensorReading] = {}
_lock = threading.Lock()

def _update_sensor(sensor_id: str) -> SensorReading:
    reading = _make_reading(sensor_id)
    with _lock:
        _readings[sensor_id] = reading
    return reading

Compute the value outside the lock. Lock only for the store. Minimize what the lock protects. Chirp does not pretend this state is magically safe; it gives a clear rule for the cases that are genuinely shared.


The threading model, summarized

List table has no rows

The framework handles the first two automatically. The third is explicit and deliberately boring. The fourth is enforced by the freeze mechanism.

That is the real usability goal: app authors get a small set of clear patterns without needing to become concurrency specialists just to build correct apps.


Further reading