Skip to content

Where the time goes in a Sillo request, which server and worker settings change throughput the most, what each middleware form costs, and how to measure your own app.

Most of the time in a request is not spent in Sillo. For a route that does almost nothing, the framework itself costs roughly 40 µs. The server, the event loop and the network cost several times that, and your handler and its database calls cost more again. So the order to work in is:

  1. Run a production server and enough workers.
  2. Fix what your own handlers and queries spend (measure them).
  3. Only then look at what Sillo itself costs (the per-request numbers).

The same Sillo app, the same three routes, one worker, different servers:

Serverplaintextjsonrows (200 objects)
uvicorn with asyncio and h11 (plain pip install uvicorn)2,971 req/s2,837 req/s1,205 req/s
uvicorn[standard]: uvloop and httptools8,370 req/s6,753 req/s1,658 req/s
granian, a Rust server, ASGI mode12,667 req/s11,830 req/s1,748 req/s

Installing uvloop and httptools is the largest single change you can make, and it needs no code. uvicorn uses them automatically once they are installed:

Terminal window
pip install "uvicorn[standard]"
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4

If you want to be certain which ones are in use, name them explicitly: uvicorn app.main:app --loop uvloop --http httptools.

Granian is an HTTP server written in Rust that serves any ASGI application, Sillo included:

Terminal window
pip install granian
granian --interface asgi --workers 4 app.main:app

It was the fastest option in the table above. Two things to know before you switch:

  • I ran plain HTTP routes only against it. Check your WebSocket routes and your lifespan start-up and shutdown hooks under it before relying on it; they are the parts of an ASGI server where implementations differ most.
  • Its process, signal and logging options are its own. Your process manager and log collector will want a look.

On the rows column the three servers are within about 45 % of each other because that route is dominated by turning 200 objects into JSON inside Sillo, which no server can speed up. See the JSON section below.

One Python process runs one request’s Python code at a time. More workers is more requests at once, up to your core count:

ServerWorkersplaintextjsonrows
uvicorn[standard]18,370 req/s6,753 req/s1,658 req/s
uvicorn[standard]424,330 req/s21,595 req/s5,633 req/s
granian427,948 req/s26,890 req/s5,783 req/s

Published framework benchmarks that show tens of thousands of requests per second are almost always several workers, a fast event loop and a fast HTTP parser, on a server-class machine. Compare like with like: the number you want is “one worker, uvloop” for judging a framework, and “all cores” for judging what your deployment can take.

A reasonable starting point is one worker per CPU core. Then check:

  • Memory. Every worker holds its own copy of your app, its caches and its connection pool.
  • Database connections. The pool size is per worker. Four workers with a pool of 20 is 80 connections, which can exceed what your database allows.
  • SQLite. Several workers writing one file contend for locks. See Workers and SQLite do not mix.
  • In-memory state. An in-process cache, rate limiter or queue is not shared between workers. Use Redis for anything that must be.

Measured in-process (the ASGI app is called directly, with no server and no network), one core:

What the request doesCost
A route that returns a stringabout 40 µs
Returns a small JSON objectabout 60 µs
Reads an integer path parameterabout 65 µs
Reads three query parametersabout 100 µs
Validated JSON POST body (Pydantic)about 85 µs
Three nested Depend() dependenciesabout 8 µs more
Each raw ASGI middleware layerunder 1 µs
Each (ctx, call_next) function middleware layerabout 0.5 ms
Each route in front of the matching oneabout 0.3 µs

A function middleware, and a BaseMiddleware subclass, can see and change the response, so the rest of the request runs in its own task whose output is handed back through an in-memory stream. That is a few event-loop round trips per layer, and it is the one place where a Sillo feature costs more than the handler it wraps. For most apps half a millisecond per layer is invisible next to a database call. It is worth knowing about if you have several layers on a route that answers in a millisecond.

Raw ASGI middleware does not do this: it wraps the call and adds almost nothing. Where a layer only needs to read the request, or add a header as the response starts, an ASGI middleware is the cheaper way to write it. Where it needs to read or replace the response body, the function form is the right one, and the cost is the price of that.

Routes are tried in order and the first match wins. Each route is checked with a cheap prefix comparison before its pattern is run, so a route that cannot match the path costs well under a microsecond. An application with 300 routes spends roughly 100 µs on routing for a request that matches the last one, and a request for an early route pays almost nothing. You do not need to reorganise routes for speed.

Return a plain dict or list and Sillo hands it straight to the C JSON encoder, asking Python only about the values the standard library cannot write itself: datetimes, UUIDs, Decimals, Pydantic models, enums. A payload of 200 rows took:

PayloadTime
200 rows of strings and numbersabout 560 µs
200 rows each with a UUID, a datetime and a Decimalabout 1.9 ms

Returning the value and wrapping it in json(...) cost about the same. If a payload is large and hot, the best saving is usually to send less: paginate, drop fields the client does not read, and cache the rendered response.

Guessing is how people spend a week optimising a route that was never slow.

1. Find the slow routes first. Time requests per route in your own logs (the RequestId middleware gives every request an ID to correlate them by) so you know which routes are slow and which are merely frequent.

2. Profile the framework and your handler together, without a server. This removes the network and the load generator from the picture:

import asyncio, cProfile, pstats
from app.main import app # your SilloApp
def scope(path: str) -> dict:
return {
"type": "http", "asgi": {"version": "3.0"}, "http_version": "1.1",
"method": "GET", "path": path, "raw_path": path.encode(),
"query_string": b"", "root_path": "", "scheme": "http",
"headers": [(b"host", b"localhost")],
"client": ("127.0.0.1", 5000), "server": ("127.0.0.1", 80),
}
async def hit(path: str, times: int) -> None:
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
pass
for _ in range(times):
await app(scope(path), receive, send)
asyncio.run(hit("/orders", 200)) # warm up
profile = cProfile.Profile()
profile.enable()
asyncio.run(hit("/orders", 2000))
profile.disable()
pstats.Stats(profile).sort_stats("tottime").print_stats(15)

tottime is the time spent in a function itself, which is the column that points at the culprit. A handler that waits on a database will show up as event-loop time, not as CPU: for that, log how long each query takes.

3. Load-test over real HTTP last, with the server and settings you will deploy and the load generator on a different machine:

Terminal window
oha -z 20s -c 64 http://127.0.0.1:8000/orders

Run each configuration several times and compare medians. A difference of a few percent between single runs is noise.

benchmarks/ in the Sillo repository compares Sillo with FastAPI, Starlette, Django and Flask over real HTTP, one worker each, on plain uvicorn. That is deliberate: it isolates what each framework adds. It is therefore not a prediction of what your deployment will do. A production setup that follows this page will be several times faster than any figure it prints.