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 6 — Free-Threading in the Bengal Ecosystem. This is the user-facing layer — what it's like to build apps on a free-threading-ready stack.
- Part 1: Bengal — Parallel rendering, immutable snapshots
- Part 2: Kida — Copy-on-write, immutable AST
- Part 3: Patitas — Parallel parsing, O(n) lexer
- Part 4: Rosettes — Local-only state machines
- Part 5: Pounce — Thread-based ASGI workers
- Part 6: Chirp — Request isolation, frozen config (you are here)
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
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
- Chirp documentation — full reference including type-driven responses, HTMX patterns, and examples
- Type-Driven Responses — how Chirp eliminates
make_response()with structural pattern matching - Start of series: Bengal SSG — Built for Python's Free-Threaded Future
Related
- Chirp vs Flask vs FastAPI — when free-threading matters for web frameworks
- The Python Free-Threading Ecosystem in 2026 — who's ready for NoGIL