FastAPI Static Files on the CDN: What Vercel Actually Changed

FastAPI already knows how to serve static files. The Vercel change is about what happens after that response exists.
A typical application still mounts StaticFiles in the usual way:
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
app = FastAPI()
app.mount(
"/assets",
StaticFiles(directory="frontend/dist/assets"),
name="assets",
)
FastAPI owns the origin-side decision. It maps /assets/app.js to a file, checks whether the file exists, builds the response, and passes it through the application stack.
Vercel can then serve an eligible static response from its CDN. On a cache hit, the request may finish before FastAPI runs at all. On a miss, bypass, or dynamic request, it still reaches the origin.
That distinction is the whole story:
browser
|
v
Vercel routing and CDN
| \
| \ cache miss, bypass, or dynamic request
| \
v v
cached asset FastAPI origin
|
v
middleware and dependencies
|
v
StaticFiles or API route
The application still defines the route. The platform changes where later responses can be delivered.
That has consequences for route precedence, middleware, dependencies, bundle inclusion, cache headers, observability, rollback, and failure handling. A static file being present in the FastAPI directory doesn't prove that it will be included in the deployment, promoted to the CDN, or served with the cache policy you intended.
What Vercel Actually Changed
FastAPI's StaticFiles is still an ASGI application mounted below a path. Its job is local resolution:
- Match the mounted path.
- Resolve the remaining path inside the configured directory.
- Return the file if it exists.
- Produce the configured missing-file response otherwise.
Without another layer, every request reaches the application origin.
Vercel's CDN promotion changes the delivery path for eligible static responses. It doesn't replace FastAPI's router. It doesn't make every request below the mount static. It doesn't make a dynamic endpoint safe to cache merely because its URL ends in .json.
The origin still establishes correctness.
The Origin Still Defines The Response
Suppose the frontend build is mounted like this:
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
BASE_DIR = Path(__file__).resolve().parent
ASSET_DIR = BASE_DIR / "frontend" / "dist" / "assets"
app = FastAPI()
app.mount(
"/assets",
StaticFiles(directory=ASSET_DIR),
name="assets",
)
If /assets/app.js reaches FastAPI, the origin must answer several questions:
- Does the file exist?
- Does the path resolve inside the intended directory?
- Which status code should be returned?
- What content type should be sent?
- Did middleware alter the response?
- Is the response safe to share?
- Which cache headers should it carry?
The CDN does not replace those decisions. It stores and replays a response after the platform accepts it for caching.
That means CDN promotion can make a bad origin response more effective at being wrong. An incorrect content type, an HTML fallback returned for a missing JavaScript file, or an overly broad cache policy can spread faster once the response is promoted.
Migration should therefore begin with origin behavior. Make the mounted application predictable first. Then verify how Vercel delivers and reports that response.
A Cache Hit Is Not A Second FastAPI Request
A cache hit can avoid the origin entirely:
CDN hit:
request -> CDN -> response
CDN miss:
request -> CDN -> Vercel routing -> FastAPI middleware
-> StaticFiles or API route -> response
-> CDN stores an eligible response
For the first path:
- FastAPI middleware doesn't execute.
- FastAPI dependencies aren't resolved.
- The mounted
StaticFilesapplication doesn't run. - Origin access logs don't contain the request.
- Origin tracing doesn't describe the request.
That is normally what you want for a public frontend bundle. It becomes a problem when a supposedly static response depends on identity, tenant, locale, cookies, authorization, or other request state.
The useful question isn't “Is this a file?” It is:
Is the same representation safe to serve to every request that can receive this cached object?
A public, versioned JavaScript bundle usually passes that test. A customer-specific export returned as a file usually doesn't.
Static Promotion Doesn't Make Dynamic Routes Static
A static asset and a dynamic endpoint can live in the same deployment without sharing a delivery policy:
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
app = FastAPI()
app.mount(
"/assets",
StaticFiles(directory="frontend/dist/assets"),
name="assets",
)
@app.get("/api/config")
async def config():
return JSONResponse({"environment": "production"})
/assets/app.css is a candidate for CDN delivery. /api/config remains an application response unless the surrounding deployment explicitly gives it a different policy.
Don't infer caching behavior from the repository layout or file extension. A dynamically generated /api/config response is still dynamic if it returns JSON. A file called /assets/config.json may also be dynamic in practice if its contents depend on deployment state or request context.
The Architecture Has Two Route Maps
A CDN-backed FastAPI application has two routing systems:
Vercel routing
|
`-- Can the request be served as a promoted static asset?
|
|-- yes: CDN response
`-- no: forward to origin
FastAPI routing
|
|-- dynamic routes
|-- mounted StaticFiles applications
`-- fallback routes
FastAPI only evaluates the second map when Vercel sends the request to the origin.
That is why correct FastAPI route ordering doesn't automatically explain public behavior. The edge may have completed the request before FastAPI saw it.
A Mount Is A Route Boundary
When you mount StaticFiles, the mount becomes part of the FastAPI route table:
app.mount(
"/static",
StaticFiles(directory="static"),
name="static",
)
A request such as /static/site.css is delegated to the mounted application. The mount isn't merely a directory alias. It claims a path family.
A dynamic route under the same prefix creates ambiguity:
app.mount(
"/static",
StaticFiles(directory="static"),
name="static",
)
@app.get("/static/version")
async def version():
return {"version": "current"}
This design has two owners for /static/version. Depending on registration order and matching behavior, the mounted application may handle the request before the dynamic route. If the file doesn't exist, the result may not be the dynamic response you expected.
Keep static and dynamic namespaces separate:
app.mount(
"/assets",
StaticFiles(directory="static"),
name="assets",
)
@app.get("/api/version")
async def version():
return {"version": "current"}
The names aren't special. The separation is.
Broad Fallbacks Need Extra Care
Single-page applications often use a catch-all route:
@app.get("/{path:path}")
async def frontend_fallback(path: str):
...
That can be useful for client-side navigation, but it must not turn missing assets into successful HTML responses.
A request for /assets/app.js should resolve through the static mount. If the file is missing, returning index.html with status 200 is usually worse than returning a clear 404. The browser may report a JavaScript MIME error, while your server dashboard records a successful request.
Keep the fallback away from the asset namespace:
app.mount(
"/assets",
StaticFiles(directory="frontend/assets"),
name="assets",
)
@app.get("/api/health")
async def health():
return {"ok": True}
@app.get("/{path:path}")
async def frontend_fallback(path: str):
return {"path": path}
This example doesn't guarantee a particular route outcome without testing the concrete application. It does make the intended ownership visible.
The platform layer needs the same discipline. A rewrite that sends every path to FastAPI can change how static requests arrive at the origin. A rewrite that sends every path to a frontend document can hide missing assets behind an HTML response.
When investigating a problem, compare:
requested public URL
|
v
URL that reaches FastAPI
Don't assume they're identical.
Prefer Non-Overlapping Namespaces
A practical route table often looks like this:
/assets/* public static files
/api/* dynamic application endpoints
/health operational endpoint
/* optional frontend fallback
That separation makes several things easier:
- public routes are obvious;
- cacheable responses are easier to identify;
- dependencies stay attached to dynamic routes;
- origin logs are easier to interpret;
- edge routing rules are less likely to collide with application paths.
A document route still needs separate consideration. /dashboard might be a static HTML file, a dynamic page, or a frontend fallback. The URL alone doesn't establish its cache behavior.
Middleware And Dependencies Are The Big Exception
FastAPI middleware wraps the application. If a request reaches the origin, middleware can run before and after the mounted StaticFiles application:
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
app = FastAPI()
class RequestHeaderMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["X-Application"] = "frontend-origin"
return response
app.add_middleware(RequestHeaderMiddleware)
A CDN hit doesn't pass through this middleware.
That distinction is easy to miss because the origin response and CDN response may look identical during a quick test. They aren't produced by the same execution path.
Middleware Can Make Assets Request-Specific
Some middleware is naturally compatible with public static assets:
- access logging;
- request IDs;
- compression negotiation;
- static security headers;
- origin timing information.
Other middleware can make a response unsafe to share:
- authentication;
- tenant selection from a cookie or host;
- locale selection;
- session-dependent content;
- user-specific authorization headers;
- response behavior based on request state.
Consider this example:
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
app = FastAPI()
class ThemeMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if request.cookies.get("theme") == "dark":
response.headers["X-Theme"] = "dark"
else:
response.headers["X-Theme"] = "light"
return response
app.add_middleware(ThemeMiddleware)
Even if the middleware only changes a header, the response now varies by cookie. If the CDN stores one representation and serves it to later requests, that header may not describe the later request.
For a public asset namespace, middleware should either be independent of user state or explicitly skip the asset path:
class RequestHeaderMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
if not request.url.path.startswith("/assets/"):
response.headers["X-Application"] = "frontend-origin"
return response
This only changes origin behavior. It doesn't configure Vercel's CDN and doesn't override edge routing. It simply makes the application-side exception deliberate.
Origin Middleware Isn't CDN Middleware
The execution boundary is:
Origin request:
CDN -> FastAPI middleware -> StaticFiles
Cache hit:
CDN -> response
Classify headers before migrating:
- Content headers describe the asset.
- Cache headers describe reuse.
- Security headers must be applied consistently where the response is served.
- Debug headers should not be required for correctness.
- User-specific headers don't belong on publicly shared assets.
If a security guarantee depends on FastAPI middleware, verify that the CDN applies the same guarantee to cached responses. Origin middleware cannot modify a response it never sees.
Don't disable caching by default. Move required behavior to the layer that actually handles the request.
Dependencies Belong On Dynamic Boundaries
FastAPI dependencies are a good fit for user-specific routes:
from fastapi import Depends, FastAPI
app = FastAPI()
async def require_user():
return {"id": "example"}
@app.get("/api/profile")
async def profile(user=Depends(require_user)):
return user
/api/profile depends on the caller. A shared CDN response would be unsafe unless the cache key and access model explicitly account for that variation.
StaticFiles is mounted as an ASGI application, not declared as a normal path operation with the same dependency interface. If a file needs authorization, protect it through an explicit dynamic route or another access-controlled delivery design.
from fastapi import Depends, FastAPI, HTTPException
app = FastAPI()
async def require_user():
return {"id": "current-user"}
@app.get("/downloads/{document_id}")
async def download_document(
document_id: str,
user=Depends(require_user),
):
if not user:
raise HTTPException(status_code=401)
return {"document_id": document_id}
This is not equivalent to a public build directory:
app.mount(
"/assets",
StaticFiles(directory="frontend/dist/assets"),
name="assets",
)
A public JavaScript bundle and a private customer export are both files. They don't have the same caching contract.
Global Middleware Can Accidentally Protect Static Files
A globally applied authentication middleware can break normal browser asset requests:
@app.middleware("http")
async def require_token_for_everything(request, call_next):
token = request.headers.get("authorization")
if not token:
return JSONResponse(
{"detail": "Not authenticated"},
status_code=401,
)
return await call_next(request)
If /assets/app.js is public, a browser's <script> request generally won't carry the expected application authorization header. Either keep authentication at the dynamic route boundary or explicitly exclude public assets:
@app.middleware("http")
async def require_token_for_dynamic_routes(request, call_next):
if request.url.path.startswith("/assets/"):
return await call_next(request)
token = request.headers.get("authorization")
if not token:
return JSONResponse(
{"detail": "Not authenticated"},
status_code=401,
)
return await call_next(request)
This is still a simplified pattern. Prefix checks should match the application's actual routing, including slash behavior and encoded paths. Test them through the public deployment.
Bundle Inclusion Is Not CDN Eligibility
A static file can fail for two separate reasons:
- It isn't present in the deployed bundle.
- It is present, but its response isn't promoted or reused at the edge.
Those failures can look identical in a browser. They need different fixes.
Make The Deployed Path Stable
Local development often relies on the current working directory:
app.mount(
"/assets",
StaticFiles(directory="frontend/dist/assets"),
name="assets",
)
Resolve paths from the module location where possible:
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
BASE_DIR = Path(__file__).resolve().parent
ASSET_DIR = BASE_DIR / "frontend" / "dist" / "assets"
app = FastAPI()
app.mount(
"/assets",
StaticFiles(directory=ASSET_DIR),
name="assets",
)
This fixes relative-path surprises. It doesn't guarantee the directory is included in the deployment. The build still has to produce the files, and the deployment still has to package them.
Verify the generated artifact:
find frontend/dist -type f | sort
Then compare it with the references in the generated HTML:
grep -oE '(/assets/[^"]+)' frontend/dist/index.html | sort -u
A correct Python path cannot serve a file that was never shipped.
Separate The Three Decisions
These are different questions:
Bundle rule:
Is the file present in the deployment artifact?
Route rule:
Is the file reachable at this public URL?
Cache rule:
If returned, can the response be reused at the CDN?
Don't use a cache setting to solve a bundle problem. Don't use a bundle exclusion to solve a cache-bypass problem.
When a deployment behaves differently from local development, check in this order:
- The build produced the file.
- The deployment included the file.
- FastAPI can resolve the file at the expected path.
- The origin response has the expected status and headers.
- Vercel routes the public URL to the intended origin or static path.
- The response is eligible for promotion.
- Later requests reuse the response as expected.
The supplied material does not identify one universal Vercel exclusion flag for every FastAPI deployment. Configuration fields and build behavior are deployment-specific. Use the current Vercel documentation and inspect the generated deployment artifact rather than copying a setting from an unrelated framework example.
Treat Build Outputs As Public API Surface
A frontend build may produce:
frontend/dist/
├── assets/
│ ├── app.abc123.js
│ ├── styles.abc123.css
│ ├── logo.abc123.svg
│ └── font.abc123.woff2
├── index.html
├── manifest.json
└── source-map files
Those files don't share one policy.
| File | Main concern |
|---|---|
| Hashed JavaScript and CSS | Public delivery and long-lived caching |
| Hashed images and fonts | Public delivery and correct content type |
| HTML entry document | Freshness and coordinated asset references |
| Manifest | Release coordination and revalidation |
| Source maps | Public exposure and source disclosure |
| Runtime configuration | Potentially dynamic or sensitive data |
A source map generated for debugging may not belong in public delivery. A runtime configuration file may need to be generated by a dynamic route. A manifest may be public but still need a shorter freshness policy than immutable bundles.
The safest configuration makes accidental promotion difficult.
Compression Doesn't Fix Routing
Compression can reduce transfer size. It doesn't fix:
- a missing file;
- a wrong mount;
- an overlapping route;
- stale content;
- an authorization error;
- an HTML fallback returned for a script.
A build may produce compressed variants:
assets/app.abc123.js
assets/app.abc123.js.gz
assets/app.abc123.js.br
Whether those files should be uploaded directly or whether the platform handles content negotiation depends on the deployment setup. Don't assume suffixes alone make the CDN select the right representation.
Verify the public response:
curl -sS -D - -o /dev/null \
https://example.com/assets/app.abc123.js
Check Content-Type, Content-Encoding, and the response body when necessary.
Cache Headers Define A Release Contract
CDN promotion is useful because it reduces origin work and places public assets closer to users. The trade-off is that a cached response can outlive the deployment state you had in mind.
The right question isn't “Can this file be cached?” Most frontend assets can.
The right question is:
For how long can this exact URL remain correct?
Hashed And Stable Files Have Different Lifecycles
A hashed asset might look like:
/assets/app.8f31c2.js
/assets/styles.4a12de.css
When the bytes change, the name changes. New HTML points to the new URL. The old URL can remain cached for clients still using an older document.
A stable asset looks like:
/assets/app.js
/assets/styles.css
The bytes change behind the same URL. Freshness now depends on revalidation or invalidation.
The relationship is:
content-hashed filename
-> changed content gets a new URL
-> long reuse is easier to justify
stable filename
-> bytes change at the same URL
-> freshness needs more care
This isn't Vercel-specific. It is the ordinary relationship between URL identity and cache correctness. A CDN makes it more visible because there may be an edge cache in addition to the browser cache.
Cache-Control Must Match The URL
For a versioned, immutable asset, teams commonly choose a long-lived public policy:
Cache-Control: public, max-age=31536000, immutable
That policy is only sensible when the URL changes whenever the content changes.
For a stable file, a revalidation-oriented policy may be safer:
Cache-Control: public, max-age=0, must-revalidate
These are examples, not universal Vercel directives. The correct policy depends on the file lifecycle and the deployment's behavior.
Don't apply an aggressive policy simply because a file extension looks static. /assets/config.json may be generated per deployment. /assets/service-worker.js can have update behavior that deserves separate treatment. A document that references a bundle has a different freshness role from the bundle itself.
Inspect the public URL:
curl -sS -D - -o /dev/null \
https://example.com/assets/app.8f31c2.js
Check the deployed response, not only localhost. Origin and edge headers can differ.
ETag And Last-Modified Are Validators
Static responses may include ETag or Last-Modified. A browser or intermediary can use those validators to check whether its stored representation remains current.
For example:
GET /assets/app.js HTTP/1.1
If-None-Match: "8f31c2-..."
A matching representation can produce:
HTTP/1.1 304 Not Modified
A 304 response doesn't prove that FastAPI opened the file for that request. The CDN may have handled validation using its own stored metadata.
Inspect the actual response:
curl -sS -D - -o /dev/null \
https://example.com/assets/app.js
Look for:
Cache-Control;ETag;Last-Modified;Content-Type;Content-Encoding;- cache-status headers, where the platform exposes them.
Don't build monitoring around a platform-specific header until the current documentation confirms its behavior. Delivery metadata is useful, but it isn't an application API.
HTML Freshness Matters Too
Versioned assets don't solve stale HTML by themselves.
Consider this sequence:
new HTML exists at origin
new JavaScript exists at origin
CDN still serves old HTML
old HTML still references old JavaScript
The browser may never request the new bundle. The CDN isn't necessarily serving the wrong JavaScript. It is serving a document that still points to the old one.
Treat the HTML entry point as part of the release contract. Decide whether it is:
- dynamically rendered;
- short-lived at the CDN;
- revalidated;
- explicitly invalidated;
- served from a separate static path.
An application can have perfect asset hashing and still appear stale if the document that discovers those assets remains cached.
Cache Scope Must Match Content Scope
Be cautious with assets affected by:
- cookies;
- authorization headers;
- tenant hostnames;
- locale negotiation;
- user-agent-specific rendering;
- request-time feature flags;
- query parameters;
- deployment identity.
If query parameters select variants, verify how the platform keys its cache. If a file is genuinely public and invariant, keep its representation independent of those inputs. If it isn't, keep it dynamic or use an explicit variant design.
The URL's extension is not the cache policy. The request and response contract is.
Observability Has Two Paths
After promotion, origin logs no longer represent total asset traffic. A drop in FastAPI requests may be a success. It may also mean the wrong layer is intercepting requests or serving stale content.
A browser failure can occur without a corresponding FastAPI log entry.
Origin Logs Show Misses And Bypasses
Application logs remain useful for:
- cache misses;
- missing files;
- incorrect rewrites;
- middleware failures;
- deployment path mistakes;
- dynamic endpoints.
A small middleware can record origin asset requests:
import logging
from fastapi import FastAPI, Request
logger = logging.getLogger("frontend-origin")
app = FastAPI()
@app.middleware("http")
async def log_origin_request(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/assets/"):
logger.info(
"origin asset request path=%s status=%s",
request.url.path,
response.status_code,
)
return response
This only observes requests that reach FastAPI. It cannot tell you how many requests were served by the CDN.
Avoid high-cardinality metrics based on every hashed filename. Group by route family or file type:
route_family=static_javascript
route_family=static_stylesheet
route_family=static_font
route_family=dynamic_api
Keep full paths in sampled logs when needed for debugging.
Edge Data Explains Cache Behavior
Use Vercel's deployment and request observability alongside origin logs. Look for:
- edge hit or miss;
- response status;
- route selection;
- cacheability;
- origin fallback;
- edge versus origin latency.
The exact fields depend on the Vercel product and current platform behavior. Don't assume an origin access log is complete after promotion.
A useful comparison is:
first request:
edge result + origin log
later request:
edge result + no origin log expected on a cache hit
If every request reaches FastAPI, investigate promotion eligibility, response headers, route configuration, or cache state. If no request reaches FastAPI but the browser receives a bad response, investigate the edge route, cached object, or deployment artifact.
Use Diagnostic Headers Carefully
A temporary origin marker can help during testing:
@app.middleware("http")
async def mark_origin_response(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/assets/"):
response.headers["X-Served-By-App"] = "fastapi"
return response
This is evidence that FastAPI handled the request that produced that response. It is not universal proof of how the public URL was delivered. A CDN may preserve the marker in a cached object, and a later cache hit may return an old marker without contacting the origin.
Don't make such a header part of application correctness. Prefer edge metadata and correlated deployment information for long-term monitoring.
Monitor Content Errors, Not Just HTTP Errors
A 200 response for a JavaScript URL can still be wrong if it contains HTML. Track:
- asset 404s after deployment;
- HTML returned for JavaScript or CSS;
- origin 5xx responses on misses;
- cache bypasses for expected public assets;
- stale content after release;
- dynamic routes accidentally cached;
- edge errors without corresponding origin errors.
A simple content-type check can catch a common failure:
set -eu
url="${BASE_URL:?BASE_URL is required}/assets/app.8f31c2.js"
headers="$(mktemp)"
body="$(mktemp)"
trap 'rm -f "$headers" "$body"' EXIT
curl -fsS -D "$headers" -o "$body" "$url"
content_type="$(
grep -i '^content-type:' "$headers" |
tr '[:upper:]' '[:lower:]'
)"
case "$content_type" in
*javascript*|*ecmascript*) ;;
*)
echo "Unexpected content type: $content_type" >&2
exit 1
;;
esac
This isn't a full browser test. It catches a route that returned a successful but unusable response.
A Safe Migration Path
Moving from origin-only delivery to CDN promotion is less about adding one setting and more about making hidden assumptions explicit.
Inventory Static And Dynamic Routes
Start by listing routes that serve files and routes that should never be shared:
Static:
/assets/*
/favicon.ico
public fonts and images
Dynamic:
/api/*
/health
authenticated pages
request-time configuration
tenant-specific files
For every static-looking URL, record:
- source directory;
- generated build directory;
- stable or hashed filename;
- middleware requirements;
- expected cache policy;
- whether it must survive across deployments;
- whether query parameters change the content.
This inventory often finds the real issue before CDN configuration does. A “static” configuration file may be generated at request time. A broad fallback may handle missing assets. A security middleware may add request-specific behavior to every response.
Establish An Origin Baseline
Before changing delivery, test representative deployed URLs:
curl -i https://example.com/assets/app.js
curl -i https://example.com/assets/missing.js
curl -i https://example.com/api/health
Record:
- status code;
- content type;
- failure body shape;
- cache headers;
- validators;
- redirects;
- origin log entry;
- middleware behavior.
The missing-file test matters. A missing JavaScript file should not silently become an HTML document through a frontend fallback.
Introduce Promotion For A Small Set
Start with clearly public, versioned JavaScript and CSS assets. Keep mutable configuration and user-sensitive files on the origin until their contracts are understood.
After deployment, request the same asset repeatedly and compare:
status
content type
body checksum
cache headers
edge metadata
origin log count
The stronger signal isn't merely lower latency. It is that a later request returns the same correct representation without another origin execution.
Test A Release Transition
Use at least two asset versions:
/assets/app.oldhash.js
/assets/app.newhash.js
Verify:
- new HTML references the new asset;
- the new asset exists in the deployment;
- old asset behavior is understood;
- stable URLs revalidate or invalidate correctly;
- missing assets don't fall through to the wrong route;
- dynamic endpoints remain dynamic.
For hashed filenames, this is usually easier because each release creates new cache keys. Stable filenames need a direct test of old bytes versus new bytes.
Keep A Rollback Path
Don't make rollback depend on instantly deleting every cached object.
Document:
- how promotion is disabled or narrowed;
- how origin delivery is restored;
- how to verify that FastAPI receives requests again;
- whether old assets remain available;
- how cached objects are invalidated or allowed to expire;
- which deployment identifier is known good.
Keep previous hashed assets available when practical. New HTML should not reference files that are removed before clients with older HTML can update.
The exact rollback controls are Vercel deployment details. Document the controls your team actually uses rather than relying on a generic “purge the CDN” instruction.
Reliability And Failover
A CDN reduces dependence on the origin for eligible assets. It doesn't make the origin irrelevant.
The origin still handles:
- dynamic endpoints;
- cache misses;
- excluded files;
- revalidation requests;
- deployment transitions;
- authenticated content;
- files not eligible for promotion.
A cache can also hide an origin problem until a new URL or cold edge request exposes it.
Static And Dynamic Retries Are Different
A failed public asset request and a failed API mutation should not share one retry policy.
| Request | Typical failure effect | Retry considerations |
|---|---|---|
| JavaScript bundle | Application may not start | Limited retry, then clear failure state |
| CSS bundle | UI may render poorly | Limited retry or fallback |
| Image or font | Visual degradation | Fallback where practical |
| Dynamic GET | Feature or page data unavailable | Endpoint-specific retry |
| State-changing request | Possible duplicate side effects | Retry only with deliberate idempotency |
A missing hashed asset won't appear because the browser requested it three more times. A retry loop can amplify an outage.
Distinguish:
- edge delivery failure;
- origin availability failure;
- asset-not-found;
- routing mistake;
- timeout;
- unexpected content type.
A 200 HTML response for a script is an asset failure, even though it isn't an HTTP error.
Cached Assets Can Survive Origin Failure
If an asset is already cached, users may continue receiving it while the origin is unavailable. That can be useful. It can also produce partial availability:
users with warm cache -> successful asset delivery
users with cold cache -> origin failure
Monitor both groups. A healthy edge response for one asset doesn't prove that the origin can serve new assets or cache misses.
Don't assume the browser will automatically switch from a CDN URL to an origin URL. A fallback must be explicitly emitted or implemented, and it must point to a compatible file from the same release.
Client Fallbacks Need A Real Second Path
A fallback can be appropriate for optional resources:
function loadOptionalScript(primaryUrl, fallbackUrl) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = primaryUrl;
script.async = true;
script.onload = () => resolve();
script.onerror = () => {
if (!fallbackUrl) {
reject(new Error(`Unable to load ${primaryUrl}`));
return;
}
const fallback = document.createElement("script");
fallback.src = fallbackUrl;
fallback.async = true;
fallback.onload = resolve;
fallback.onerror = () => {
reject(
new Error(
`Unable to load ${primaryUrl} or ${fallbackUrl}`,
),
);
};
document.head.appendChild(fallback);
};
document.head.appendChild(script);
});
}
This only helps if:
- the fallback URL is real;
- it is deployed;
- it contains the same release-compatible code;
- it is governed by a known route;
- it isn't the same failing path under another name.
For a critical bootstrap bundle, a bounded retry followed by a visible error state is usually better than an indefinite loop.
Use A Separate Origin Fallback Prefix
If you need an emergency origin path, give it a different namespace:
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
app = FastAPI()
app.mount(
"/origin-static",
StaticFiles(directory="static"),
name="origin-static",
)
The public CDN path and origin fallback path are now distinguishable in routing and logs:
/static/app.8f31c2.js
/origin-static/app.8f31c2.js
Whether this arrangement fits a particular Vercel deployment depends on how the public asset URL is configured. The architectural point is the separation of ownership.
Don't make a broad catch-all route the fallback for missing assets. It can turn a missing script into a successful HTML response and hide the real deployment problem.
Common Migration Pitfalls
The difficult bugs are usually not caused by StaticFiles itself. They come from assumptions about which layer handles the request.
Dynamic Routes Under Static Prefixes
Avoid this:
app.mount(
"/assets",
StaticFiles(directory="assets"),
name="assets",
)
@app.get("/assets/{asset_name}")
async def dynamic_asset(asset_name: str):
return {"asset": asset_name}
Use separate ownership:
app.mount(
"/static",
StaticFiles(directory="static"),
name="static",
)
@app.get("/api/assets/{asset_name}")
async def describe_asset(asset_name: str):
return {"asset": asset_name}
If the CDN promotes some files but not others under an overlapping prefix, the edge and origin can appear to disagree about who owns a URL.
Middleware That Behaves Differently On Hits And Misses
A redirect, authentication, rewrite, or response-header middleware may run on an origin miss but not on a CDN hit.
For example:
@app.middleware("http")
async def force_https(request: Request, call_next):
forwarded_proto = request.headers.get("x-forwarded-proto")
if forwarded_proto == "http":
target = request.url.replace(scheme="https")
return RedirectResponse(str(target), status_code=307)
return await call_next(request)
Behind a platform that already handles TLS, this may be redundant. A cached response can bypass it, while an origin miss still runs it. The same URL can therefore have different behavior depending on cache state.
Keep platform-level concerns at the platform layer where possible, and test both warm and cold requests.
Stable Filenames With Long Freshness
This is the classic freshness mismatch:
/static/app.js
The deployment replaces the bytes, but the URL remains the same. A browser or edge cache can continue serving the old response until its freshness period ends or it revalidates.
If the application uses stable names, document:
- expected stale duration;
- revalidation policy;
- invalidation mechanism;
- deployment order;
- rollback behavior.
If possible, use content-derived names for compiled assets.
Old HTML And Removed Assets
A client can hold old HTML that references old assets. If the new deployment removes those files immediately, old clients can fail even though fresh sessions work.
A safer release model keeps previous hashed assets available for the period in which older HTML may still exist in browsers or intermediary caches.
The exact retention period depends on the application and deployment process. It should be chosen deliberately rather than assumed to be zero.
Missing Assets Returning HTML
Test this explicitly:
set -eu
status="$(
curl -sS -o /dev/null -w '%{http_code}' \
"https://example.com/assets/not-found.js"
)"
case "$status" in
404|410)
echo "Missing asset handled as expected: $status"
;;
*)
echo "Unexpected missing-asset status: $status" >&2
exit 1
;;
esac
The acceptable status depends on the application. The requirement is that missing-file behavior is intentional and visible.
A 200 response is not enough. Check the content type too.
A Reference Application
The following example keeps the important boundaries explicit:
from pathlib import Path
from fastapi import APIRouter, Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
BASE_DIR = Path(__file__).resolve().parent
ASSET_DIR = BASE_DIR / "frontend" / "dist" / "assets"
app = FastAPI()
app.mount(
"/assets",
StaticFiles(directory=ASSET_DIR),
name="assets",
)
async def require_internal_token(request: Request) -> None:
if request.headers.get("x-internal-token") != "expected-value":
raise PermissionError("missing internal token")
api = APIRouter(prefix="/api")
@api.get("/health")
async def health():
return {"status": "ok"}
@api.get(
"/internal/status",
dependencies=[Depends(require_internal_token)],
)
async def internal_status():
return {"status": "ok"}
app.include_router(api)
@app.middleware("http")
async def add_origin_marker(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/assets/"):
response.headers["X-Origin-Request"] = "true"
return response
The marker is diagnostic only. It proves that FastAPI produced the response that carried it. It doesn't prove that every public request reaches FastAPI, because a CDN hit can reuse the response without running the middleware.
Add tests for the route contract:
from fastapi.testclient import TestClient
client = TestClient(app)
def test_static_asset_is_served():
response = client.get("/assets/known-file.js")
assert response.status_code == 200
assert "javascript" in response.headers.get("content-type", "")
def test_missing_asset_is_not_frontend_html():
response = client.get("/assets/does-not-exist.js")
assert response.status_code == 404
def test_dynamic_endpoint_remains_dynamic():
response = client.get("/api/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
The known file must exist in the test fixture. The exact content type can vary with the serving stack, so assert the contract your deployment requires.
Testing The Public Delivery Path
An origin test proves that FastAPI can serve a file. It doesn't prove that the Vercel deployment can serve it from the public URL.
Test both where possible:
curl -sS -D - -o /dev/null \
https://example.com/assets/app.abc123.js
curl -sS -D - -o /dev/null \
https://origin.example.com/assets/app.abc123.js
The two URLs may not be exact equivalents. Host routing, redirects, TLS termination, and deployment configuration can change the response. The comparison is still useful for finding differences in:
Cache-Control;ETag;Last-Modified;Content-Type;Content-Encoding;- security headers;
- platform cache metadata.
Test Cold And Warm Requests
A promoted asset has at least two useful states:
cold:
edge has no usable object
request may reach origin
warm:
edge serves a cached object
origin may not be contacted
Record for each request:
- status;
- latency;
- response size;
- content type;
- cache status, where exposed;
- whether FastAPI logged the request;
- deployment identifier.
Don't infer a cache hit from low latency alone. A fast origin and a CDN hit can look similar.
Test New And Old Asset Names
After a release:
for asset in \
"/assets/app.oldhash.js" \
"/assets/app.newhash.js"
do
curl --fail --silent --show-error \
-D "/tmp/headers.$(basename "$asset")" \
-o "/tmp/body.$(basename "$asset")" \
"https://example.com$asset"
done
This verifies that the new HTML references a complete asset set and that old clients aren't immediately broken by removed files.
Don't assume that a new deployment automatically invalidates every object. The exact invalidation behavior depends on the Vercel configuration and platform behavior. Verify it.
Test Dynamic Routes As A Control
A CDN migration shouldn't change the semantics of dynamic endpoints:
curl -i https://example.com/api/health
curl -i https://example.com/api/profile
Check that:
- dependencies still execute;
- user-specific responses aren't shared;
- dynamic responses don't acquire public cache headers;
- origin logs and traces still appear;
- route paths aren't being swallowed by a broad static or rewrite rule.
Dynamic requests are the control group for the migration. If they change unexpectedly, investigate routing before tuning static caching.
What Remains Dynamic
The CDN-friendly part of the application is usually narrow:
public, versioned, request-independent assets
The origin-bound part is broader:
authenticated responses
tenant-specific content
request-time configuration
state-changing operations
responses depending on FastAPI dependencies
dynamic pages
health and operational endpoints
A file can belong to the second category even if it lives in a static directory. Physical storage doesn't define delivery semantics.
Runtime Configuration
A public bundle can load dynamic configuration from an origin endpoint:
from fastapi import Depends, FastAPI, HTTPException
app = FastAPI()
async def require_user():
return {"id": "current-user"}
@app.get("/api/runtime-config")
async def runtime_config(user=Depends(require_user)):
if not user:
raise HTTPException(status_code=401)
return {
"apiBaseUrl": "https://api.example.test",
}
The bundle is a CDN candidate:
/assets/app.abc123.js
The runtime configuration remains dynamic:
/api/runtime-config
Don't place request-sensitive configuration beside immutable build assets just because the browser needs both during startup.
Generated Documents
An HTML document can be static or dynamic. Its cache policy depends on how it is produced and how it references assets.
If it is generated per deployment and references hashed filenames, a short-lived or controlled policy may be appropriate. If it contains user-specific content, it should remain dynamic. If it is stable but changes in place, long-lived caching can produce stale releases.
The URL /dashboard doesn't tell you which category applies. Inspect the route and response contract.
Generated Downloads
A download route with authorization is not a public static asset:
@app.get("/downloads/{document_id}")
async def download_document(
document_id: str,
user=Depends(require_user),
):
...
If it needs per-user access checks, keep it behind the application or a delivery mechanism designed for authorization. Don't promote it merely because the response body is a file.
A Practical Incident Playbook
When a frontend asset fails after enabling CDN promotion, preserve evidence before changing several settings at once.
Capture:
asset URL
deployment identifier
status code
content type
Cache-Control
ETag
Last-Modified
cache-status metadata
whether FastAPI logged the request
time first observed
Then classify the failure.
The Asset Is Missing Everywhere
Check:
- the build produced the file;
- the deployment included it;
- the
StaticFilesdirectory points to the deployed location; - the public URL matches the mount;
- the generated HTML references the actual filename.
This is a packaging or path problem, not a cache invalidation problem.
The Origin Is Correct, But The Public URL Is Stale
Check:
- whether the URL is stable across releases;
Cache-Control;- validators;
- the cached HTML document;
- the edge response metadata;
- the platform's documented invalidation behavior.
If the asset name is stable and the response is intentionally fresh for a long time, stale delivery is the expected outcome of that policy.
The Public URL Returns HTML For JavaScript
Check:
- broad fallback routes;
- edge rewrites;
- static mount precedence;
- missing-file behavior;
- content type at the public URL.
Don't fix this with retries. Make the asset route return the correct file or a clear missing-file response.
A Dynamic Response Appears Cached
Check:
- whether the path overlaps a promoted static namespace;
- whether the response has public cache headers;
- whether a rewrite maps the dynamic route to a static path;
- whether request-specific data is in the cache key;
- whether the response is coming from an older cached object.
A dynamic endpoint should not become shareable merely because it returns JSON or has a file-like URL.
Origin Traffic Suddenly Drops
That may be a successful promotion. Compare it with:
- public asset request volume;
- edge hit or miss data;
- asset error rates;
- content correctness;
- deployment timing.
A quiet origin is not proof of a healthy edge. It is one signal.
The Operational Contract
The cleanest design has a small number of explicit rules:
StaticFilesowns one clearly named public namespace.- Dynamic routes live outside that namespace.
- Versioned assets use long-lived caching only when their URLs change with content.
- Stable URLs have a deliberate revalidation or invalidation policy.
- Middleware and dependencies are not assumed to run on CDN hits.
- Bundle inclusion is checked separately from CDN eligibility.
- Edge logs and origin logs are treated as different evidence.
- Missing assets don't silently become frontend HTML.
- Old assets remain available long enough for older HTML to work.
- Rollback changes delivery policy in a documented way.
The Vercel change is valuable because repeated public asset requests can terminate at the edge instead of consuming FastAPI execution. It also makes the system more distributed. The application still defines the origin route, but it no longer observes every request to that route.
That is the design boundary to preserve: let the CDN handle responses that are public, reusable, and versioned. Keep request-sensitive behavior in FastAPI, where middleware, dependencies, and current origin state can still participate.
FAQs
Does Vercel replace FastAPI StaticFiles?
No. FastAPI still owns the origin-side decision: it maps the requested path to a file, checks whether the file exists, builds the response, and passes it through the application stack. Vercel can serve an eligible static response from its CDN after that response exists.
What happens when a static asset is served from a Vercel CDN cache hit?
On a cache hit, the request may finish before FastAPI runs at all. FastAPI middleware, dependencies, and the mounted StaticFiles application do not execute, and the request may not appear in origin logs or tracing.
Does CDN promotion make every route under a static path static?
No. A StaticFiles mount claims a path family, but dynamic routes under the same prefix can create ambiguous ownership. Keep static and dynamic namespaces separate, such as using /assets for public files and /api for application endpoints.
Can middleware affect CDN-served FastAPI assets?
Origin middleware only runs when the request reaches FastAPI. A CDN cache hit bypasses it, so headers or behavior added by middleware may not appear consistently unless the CDN applies the same policy to cached responses.
Should authenticated or user-specific files be served through a public StaticFiles mount?
No. A public JavaScript bundle and a private customer export do not have the same caching contract. User-specific files should be protected through an explicit dynamic route or another access-controlled delivery design rather than a publicly shared static namespace.
How can a FastAPI application prevent missing assets from becoming HTML fallbacks?
Keep the asset namespace separate from broad frontend fallback routes. A missing request such as /assets/app.js should normally return a clear 404 rather than index.html with status 200, because an HTML fallback can produce browser MIME errors while appearing successful in server metrics.
Is a static file being present in the FastAPI directory enough for it to reach the Vercel CDN?
No. The file must first be produced by the build and included in the deployment artifact, then be reachable through the expected route and return the intended response and headers. Bundle inclusion, route reachability, and CDN cache eligibility are separate decisions.
How should cache behavior be evaluated for FastAPI responses?
Ask whether the same representation is safe to serve to every request that can receive the cached object. Public, versioned JavaScript bundles usually meet that requirement, while responses that vary by identity, tenant, locale, cookies, authorization, or other request state generally do not.
Work with us
Let's build something together
We build fast, modern websites and applications using Next.js, React, WordPress, Rust, and more. If you have a project in mind or just want to talk through an idea, we'd love to hear from you.
Related Articles
Engineering • 22 min
GPT-6 Astra on AI Gateway: The Model Is Only Half the Agent
How to use GPT-6 Astra through Vercel AI Gateway without confusing model capability, routing, budgets, observability, and authorization.
9/9/2026
Engineering • 22 min
Vercel Sandbox Routing Got Faster. Your Agent Still Has Work To Do.
What Vercel Sandbox's regional domain routing changes, what it does not, and how to measure the latency that matters for agent workloads.
9/9/2026
WordPress • 7 min
Is Nextpress the True WordPress Killer? A 2026 Stack Analysis
Analyze why Next.js is not a WordPress killer. Explore Nextpress as a unified CMS stack, Vercel deployment, and the shift from monolithic to headless architectures in 2026.
5/23/2026