Skip to content

Serving Static Files from an ASGI App

Most ASGI apps serve a few static files — a favicon, a JavaScript bundle, downloadable reports — and the usual advice is to put them behind a CDN or reverse proxy. When that is not possible, or not yet in place, the choice of server and mount decides how much the files cost. Measured on Python 3.14 with Starlette 1.7.0, uvicorn 0.54 and Granian 2.8.4, one worker process each, with an aiohttp load client: Starlette's StaticFiles on uvicorn served a 10 KiB file 2,979 times per second and a 20 MB file at 824 MB/s, using 121–136% CPU. The same app on Granian, which supports the ASGI pathsend extension so that the server sends the file itself, reached at least 6,975 small files per second and 2,848 MB/s. Granian's own static mount, which never enters Python, reached at least 19,768 per second and 3,384 MB/s — in both Granian cases the load client was the limit. The native mount was fastest, but it sent no ETag or Last-Modified and answered a range request with the full 20 MB; StaticFiles returned 304 for a matching If-None-Match and 206 with 100 bytes for the range. This guide measures the options and the features each one gives up.

Prerequisites

1. Mount StaticFiles and check its headers

Starlette's StaticFiles maps a URL prefix to a directory:

from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles

app = Starlette(routes=[
    Route("/ping", ping),
    Mount("/static", StaticFiles(directory="files"), name="static"),
])

Before measuring speed, check behaviour with curl. Measured on uvicorn: the response carried content-length, accept-ranges: bytes, last-modified and an etag. A request with If-None-Match set to that ETag returned 304 Not Modified with no body; a request with Range: bytes=0-99 returned 206 Partial Content with exactly 100 bytes; and /static/../app.py, sent unnormalized with curl --path-as-is, returned 404. Those three behaviours — revalidation, ranges, and refusing to leave the directory — are what a static file server is for, and any faster option should be checked for them too.

Verify: curl -I shows etag and last-modified, a conditional request returns 304, and a range request returns 206.

2. Measure StaticFiles on uvicorn

Load the mount with concurrent clients while a probe times a trivial route, and record the server's CPU — including any child processes:

async def fetcher():
    while time.monotonic() < stop:
        async with session.get(f"{BASE}/static/{name}") as r:
            async for chunk in r.content.iter_chunked(1 << 20):
                nbytes += len(chunk)
        n += 1

Measured on uvicorn with 16 clients for the 10 KiB file and 8 for the 20 MB file: 2,979 small files per second with the server at 121% CPU, and 41.2 large files per second — 824 MB/s — at 136%. The load client used 37–38% CPU, so the server was the limit. StaticFiles reads files through a thread in 64 KiB chunks, so a 20 MB response is about 305 thread round trips; the event loop stayed responsive throughout, with the probe's p99 at 1.8–3.0 ms. For a handful of assets this is plenty. For a busy download route it is the slowest of the options measured.

Verify: the probe's p99 stays low during the static load test, and the static throughput covers expected peak traffic with margin.

One worker process serving static files A grid of 4 rows by 4 columns. One worker process serving static files setup 10 KiB file 20 MB file ETag, 304, ranges StaticFiles on uvicorn 2,979/s 824 MB/s yes StaticFiles on Granian (pathsend) 6,975/s or more 2,848 MB/s or more yes, from Starlette Granian --static-path-mount 19,768/s or more 3,384 MB/s or more no ETag, no ranges bytes held in memory, uvicorn 19,706/s not measured only if added by hand Figures marked "or more" were limited by the load client at 92-102% CPU.

3. Let the server send the file

The ASGI http.response.pathsend extension lets an application hand the server a file path instead of streaming the bytes through Python. Starlette's FileResponse uses it automatically when the server advertises it in scope["extensions"]; Granian does, uvicorn 0.54 does not:

granian --interface asgi --port 8000 app:app        # same app, no code change

Measured with the same app on Granian: at least 6,975 small files per second and 2,848 MB/s for the large file, with the server at 170–183% CPU across its processes. The load client was at 63% and 92% CPU, so the large-file figure is a lower bound. The headers and conditional behaviour still come from Starlette, since only the final send is delegated: ETags, 304 responses and range handling stay as they were. For apps that already run on Granian, this is a free improvement; for uvicorn, it is a reason to put file-heavy routes behind something else.

Verify: "http.response.pathsend" in scope.get("extensions", {}) is true under the production server, if the speed-up is being relied on.

10 KiB file, requests per second 4 horizontal bars comparing StaticFiles on uvicorn with the others. 10 KiB file, requests per second StaticFiles on uvicorn 2,979/s StaticFiles on Granian 6,975/s+ in-memory bytes on uvicorn 19,706/s Granian static mount 19,768/s+ The last three were limited by the load client, not the server. 16 concurrent clients, one server worker.

4. Know what a native mount gives up

Granian can serve a directory itself, in Rust, before the request reaches Python:

granian --interface asgi --port 8000 \
    --static-path-route /static --static-path-mount ./files app:app

Measured: at least 19,768 small files per second — the client was at 102% CPU — and at least 3,384 MB/s. The probe's p99 stayed at 1.7 ms for small files. But the response headers were cache-control: max-age=86400, content-type and date: no etag, no last-modified, and a Range: bytes=0-99 request returned 200 with all 20 MB. Browsers therefore cannot revalidate cheaply once max-age expires, and download managers and video players cannot resume or seek. It also refused the traversal request with 404. For fingerprinted assets — app.3f9a1c.js, which never change and are cached for a year — the missing ETags do not matter. For large downloads, missing range support does.

Verify: the native mount serves only immutable, fingerprinted assets, and every route that needs ranges or revalidation goes through StaticFiles.

5. Choose a layout

A layout that fits most apps: fingerprinted build assets through the fastest path available, user-visible downloads through StaticFiles, and small hot files — a favicon, robots.txt — held in memory if they are requested often enough to matter:

FAVICON = (STATIC_DIR / "favicon.ico").read_bytes()

async def favicon(request):
    return Response(FAVICON, media_type="image/x-icon",
                    headers={"Cache-Control": "public, max-age=86400"})

app = Starlette(routes=[
    Route("/favicon.ico", favicon),
    Mount("/downloads", StaticFiles(directory="downloads")),   # ETag, 304, ranges
    Mount("/assets", StaticFiles(directory="dist")),           # or a native mount
])

Measured on uvicorn, serving the 10 KiB file from memory reached 19,706 requests per second, 6.6 times StaticFiles on the same server, because no file is opened or stat'ed per request. When traffic grows beyond one process, a CDN or reverse proxy in front of the app does better than any of these, and the app's job reduces to sending correct cache headers. Large files that are generated rather than stored belong in streaming responses; see streaming responses with Starlette and FastAPI.

Verify: each static route's needs — revalidation, ranges, immutability — are listed, and the chosen mount supports them.

Routing static content by what it needs A flow of 4 stages. Routing static content by what it needs Fingerprinted assets native mount, max-age 1 year Downloads StaticFiles: ETag, 304, 206 Tiny hot files bytes in memory Beyond one process CDN or reverse proxy Fast paths only where their missing features do not matter.

Verification

Static files are served well when:

  • Revalidation and ranges work where clients need them: 304 for a matching ETag, 206 for a range.
  • Directory traversal is refused, tested with an unnormalized ../ path.
  • Throughput is measured under the production server, with the load client's CPU checked so its limit is not mistaken for the server's.
  • Fast native mounts serve only immutable assets.

Diagnostic Hook: when large downloads restart from zero after a dropped connection, send a range request with curl -H "Range: bytes=0-99". A 200 with the whole file — as Granian's native mount returned — means the route does not support ranges.

Pitfalls & edge cases

  • Assuming every mount supports ranges. The native mount returned the full 20 MB.
  • Load tests limited by the client. Check client CPU; several results here are lower bounds.
  • Long max-age without fingerprints. Changed files stay stale in browsers for the whole period.
  • Expecting pathsend on uvicorn. uvicorn 0.54 does not advertise it.

Frequently Asked Questions

Is Starlette StaticFiles fast enough for production?

For a few assets, yes: 2,979 small files per second on uvicorn with one worker. For heavy static traffic, use a CDN or a server path that avoids Python.

What is the ASGI pathsend extension?

It lets the app hand the server a file path to send. Starlette's FileResponse uses it when available; on Granian it raised small-file throughput to at least 6,975/s.

Does Granian's static file mount support ETags?

Not in Granian 2.8.4: responses had cache-control but no ETag or Last-Modified, and a range request returned the whole file with status 200.

Should I serve a favicon from memory?

If it is requested often, yes. Serving 10 KiB from memory reached 19,706 requests/s against 2,979 through StaticFiles on the same server.