You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Regression: SWA managed Functions silently cap new deploys at ~39 app.http() registrations (deploy reports success, zero functions register, every /api/* returns 404) #1762
Sometime between 2026-06-13 and 2026-06-22, Azure Static Web Apps' managed Functions runtime introduced a hard registration cap of approximately 39 app.http() calls per deploy when using the Azure Functions v4 programming model (Node) with a single main.js entrypoint.
Crossing this cap causes a silent failure:
The GitHub Actions deploy (Azure/static-web-apps-deploy@v1) reports Deployment Complete :)
The workflow run shows ✅ success in GitHub Actions
Oryx logs show Finished building function app with Oryx, Functions Runtime: ~4, node version: 22
The SWA portal shows the build as Ready
BUT: every /api/* URL on the SWA returns 404 — and not the SPA index.html 404 fallback, the function host's own 404 page with <title>Azure Static Web Apps - 404: Not found</title> and the appservice.azureedge.net favicon
ARM REST GET /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Web/staticSites/<name>/functions?api-version=2024-04-01 returns {"value": []}
ARM REST under api-version=2023-12-01 likewise: {"id": null, "nextLink": null, "value": []}
Previously-deployed environments with >39 functions still work, because they registered before the regression. This is empirically observable on the affected SWA (details below).
Available privately on request: subscription ID, full GitHub repo URL, deploy artifact dumps, full Oryx and Azure Functions runtime logs.
Affected SWA
Resource details (publicly observable via the custom hostnames):
Field
Value
Resource name
ltmg-website
Resource group
LTMG
Subscription ID
available on request (private)
Region
Central US
SKU
Standard
API config
Managed Functions (no userProvidedFunctionApps, no linkedBackends)
A controlled bisection was performed on a test branch (test/minimal-api) and a fresh preview environment (build 9 on the affected SWA), creating a new PR each step so the function host was never reused. Same staticwebapp.config.json, same host.json, same api/package.json. Only the count of app.http() calls in api/main.js varied.
az staticwebapp environment functions --query 'length(@)'
/api/health
1
1
200 OK
21
21
200 OK
31
31
200 OK
36
36
200 OK
39
39
200 OK
40
0
404 (SWA function host 404 page)
41
0
404
67
0
404
73
0
404
145 (full production code)
0
404
Each step was a fresh PR triggering a fresh preview env build to rule out function-host caching. The 39 vs 40 boundary was also reproduced on the production default environment after the workaround was rolled back temporarily.
Specifically:
39 functions in main.js → all 39 registered. Confirmed via ARM REST GET .../functions listing all 39 by name. Each function served real traffic (400/401/etc. based on the handler's input validation).
40 functions in main.js → zero registered. ARM REST returns value: []. Direct HTTP requests return the SWA's branded 404 page from the function host, not the app's index.html. Identical deploy artifact otherwise.
The boundary holds at 39/40 with the simple test handlers above. Production handlers (body parsing, DB connections, blob clients, etc.) do not change the threshold.
Strongest single piece of evidence: build 6 vs build 9
Two preview environments on the same SWA, deployed from the same GitHub repository, same deploy action version, same secrets, but at different times:
$ az staticwebapp environment list --name ltmg-website -g LTMG \
--query '[?name==`6` || name==`9`].{name:name,createdTimeUtc:createdTimeUtc,sourceBranch:sourceBranch,status:status}' -o table
Name CreatedTimeUtc SourceBranch Status
------ -------------------------------- ---------------------- ------
6 2026-06-13T21:23:12.940175+00:00 feat/careers-page Ready
9 2026-06-22T... test/minimal-api Ready
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST \
https://gray-beach-04906f410-6.centralus.7.azurestaticapps.net/api/contact \
-H 'Content-Type: application/json' -d '{}'
400 # handler ran, rejected empty body — confirms function-execution works
$ curl -s -o /dev/null -w '%{http_code}\n' \
https://gray-beach-04906f410-9.centralus.7.azurestaticapps.net/api/health
404 # SWA 404 page; no function host response
Same SWA. Same managed Functions runtime. Same deploy mechanism. Same staticwebapp.config.json schema. Only when the build was performed differs.
Snippet of the "successful" deploy log (with zero functions registered)
For the regression-affected deploy (40 functions in main.js):
2026-06-22T02:20:42 Error: Could not detect the language from repo.
2026-06-22T02:20:42 Oryx was unable to determine the build steps. Continuing assuming the assets in this folder are already built.
2026-06-22T02:20:42 Finished building app with Oryx
2026-06-22T02:20:44 Api Directory Location: 'api' was found.
2026-06-22T02:20:44 Starting to build function app with Oryx
2026-06-22T02:20:53 Function Runtime Information. OS: linux, Functions Runtime: ~4, node version: 22
2026-06-22T02:20:53 Finished building function app with Oryx
2026-06-22T02:20:59 Uploading build artifacts.
2026-06-22T02:21:03 Finished Upload. Polling on deployment.
2026-06-22T02:21:36 Deployment Complete :)
No warning, no error, no hint that the function host then registered zero of the 40 functions present in the deployed bundle.
What's NOT the cause (ruled out during diagnosis)
We spent considerable time ruling these out — sharing in case other reporters waste time on the same theories:
Not the Oryx build. Oryx reports Finished building function app with Oryx. The function app directory is copied to /bin/staticsites/<deploymentId>-swa-oryx/api/ and dependencies installed (api/.oryx_prod_node_modules → api/node_modules). No errors at build phase.
Not the deploy authentication. The azure_static_web_apps_api_token is valid. The deploy step's polling step reports Finished Upload. Polling on deployment. then Deployment Complete :). There is no error surface at any stage — this is what makes the regression dangerous.
Not Cloudflare cache. The custom domain is fronted by Cloudflare. We confirmed by hitting the default *.azurestaticapps.net host directly: it also returns 404 for every /api/*. Cloudflare's Cf-Cache-Status: DYNAMIC confirms the response is not cached.
Not function.json conflicts. We had ~50 v3-style function.json files in api/* subdirectories alongside the v4 model in main.js. Removing all of them (git rm api/*/function.json) did not change behavior. The v4 app.http() cap holds independent of v3 function.json presence.
Not the responseOverrides 404→/index.html 200 rewrite in staticwebapp.config.json. This was making the 404 look like a 200 with HTML body, hiding the function-host response. Removing the override surfaces the real 404 but does not fix the registration failure. (Recommend other affected customers remove this override first to make diagnosis possible.)
Not workflow file duplication. Only one Deploy to Azure Static Web Apps workflow on push: main.
Not Node engine version.engines.node in api/package.json is just a warning to Oryx; the runtime version is determined by SWA. Pinning to ~20 produced an npm warn EBADENGINE but the runtime kept using Node 22 and the cap remained.
Not host.json configuration. Default extensionBundle: [4.*, 5.0.0) was used throughout. Adding extensions.http.routePrefix: "api" (a wrong guess on our part) broke things differently — produced URLs at /api/api/<route> — but with routePrefix removed the original cap behavior returned.
Not az staticwebapp disconnect/reconnect. Cycling source control had no effect on the function host's behavior. A duplicate workflow file is auto-created on reconnect and must be cleaned up.
Not an SWA deploy-key issue. We rotated the deploy key (az rest .../resetapikey), updated the GitHub secret, and observed the same registration failure on the next deploy. (Note: rotating the key without updating the GH secret causes a SEPARATE silent failure mode where the deploy upload step fails with No matching Static Web App was found or the api key was invalid and reports overall workflow success — this is also worth surfacing as an error.)
Customer impact
This is a high-severity regression because it presents as a successful deploy:
All API endpoints offline. Every /api/* URL returns 404 for the entire time the deploy is live.
Webhooks silently dropped. Stripe billing webhooks (/api/stripe-billing), SMS-provider webhooks (/api/blooio-webhook), and other vendor webhooks 404 — no DLQ, no alert. Stripe will retry per its policy; SMS providers usually do not.
Cron-style callbacks silently dropped. Internal cleanup, DNS-check, dunning-advance jobs registered with n8n / Logic Apps all 404 silently.
No error surface in standard observability. GitHub Actions: green. Azure Portal SWA blade: Ready. Build status: Ready. Application Insights (if enabled) sees zero function invocations — but customers without App Insights see nothing.
Time-to-detect is high. The customer in this case had ~24 hours of production downtime before root-causing. The marketing site, demo blobs, and login flow all kept working (served from blob / SWA's built-in auth), so visitor traffic was fine. Only the admin tools, client portal, and webhook receivers were dead.
Time-to-resolve is high. Without a clear error, customers chase token rotation, host.json changes, function.json cleanup, disconnect/reconnect, and other rat holes before considering a hard count cap. We spent ~6 hours in those dead ends.
Workaround (what we shipped on ltmg-website)
For other affected customers: consolidate your app.http() calls into "router" functions that dispatch internally by URL parameter. We went from 145 registrations to 39 by adding 8 router functions:
Router
Route template
Endpoints handled
portalRouter
portal-{op}
23
careersRouter
careers-{op}
5
proxyRouter
proxy-{op}
3
mcMidflowRouter
mc-{op}
41
opsRouter
ops-{op}
15
siteRouter
site-{op}
8
launchsiteRouter
launchsite-{op}
7
catchallRouter
{path:regex(^(a|b|c)$)}
6
Code sketch:
// Internal dispatch table; handlers can be inline v4 or v3-style required from diskconstportalHandlers={'data': require('./portal-data/index'),'team': require('./portal-team/index'),'leads': require('./portal-leads/index'),// ... 20 more};app.http('portalRouter',{methods: ['GET','POST','OPTIONS'],authLevel: 'anonymous',route: 'portal-{op}',handler: async(request,context)=>{consth=portalHandlers[request.params.op];if(!h)return{status: 404,jsonBody: {error: 'Unknown'}};returnwrapLegacy(h)(request,context);// adapter for v3-style handlers},});
Frontend URLs are unchanged. staticwebapp.config.json role-gate rules (/api/portal-* → authenticated) still apply because the wildcard matches the URL pattern regardless of how the underlying function is implemented.
Route-precedence findings (currently undocumented for v4 model)
While building the routers above, we hit two distinct route-template behaviors that are not documented in the Azure Functions HTTP trigger docs:
Single-segment parameterized routes (route: 'prefix-{op}') DO observe literal-vs-parameterized precedence. A router at route: 'mc-{op}' does NOT cannibalize an existing literal route: 'mc-kanban' registration. Empirically confirmed across all 7 prefix routers (28+ literal routes coexisting with each prefix-{op} parameterized router).
Catchall routes (route: '{*path}') do NOT observe that precedence. An un-constrained {*path} catchall WILL steal literal-route URLs. We confirmed this on build 12 (commit 7ba0c0d on the affected SWA): an un-constrained catchall stole /api/track and /api/contact even though both have literal app.http() registrations. The fix was to add a regex constraint: route: '{path:regex(^(track|contact|...)$)}'.
Both behaviors are reasonable, but the asymmetry between {op} and {*path} is not documented and surprised us during the bisection. Recommend documenting these in the v4-model HTTP trigger reference.
Asks
Confirm the regression and an internal repro on Microsoft's side.
Time-to-fix estimate so customers can decide between the router workaround (intrusive refactor) and waiting.
Surface a clear deploy-time error when the cap is hit. Currently the deploy reports success with zero registered functions. At minimum:
A WARN-level log in the GitHub Actions deploy step like WARN: <N> functions in deployment exceed registration capacity; <K> registered.
A non-success exit code from the deploy action when the function-host reports zero registrations.
Optionally: a portal banner on the SWA blade when functions are present in the deploy artifact but zero are registered.
Document the route-precedence behavior of {op} vs {*path} vs literal routes in the v4 model HTTP trigger reference.
Happy to provide additional logs, repro PR, or join a call. Resource subscription ID and other private details available on request via this issue or a private channel.
Summary
Sometime between 2026-06-13 and 2026-06-22, Azure Static Web Apps' managed Functions runtime introduced a hard registration cap of approximately 39
app.http()calls per deploy when using the Azure Functions v4 programming model (Node) with a singlemain.jsentrypoint.Crossing this cap causes a silent failure:
Azure/static-web-apps-deploy@v1) reportsDeployment Complete :)Finished building function app with Oryx,Functions Runtime: ~4, node version: 22Ready/api/*URL on the SWA returns 404 — and not the SPAindex.html404 fallback, the function host's own 404 page with<title>Azure Static Web Apps - 404: Not found</title>and theappservice.azureedge.netfaviconaz staticwebapp environment functions --name <name> --resource-group <rg> --environment-name default --query 'length(@)'returns 0GET /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Web/staticSites/<name>/functions?api-version=2024-04-01returns{"value": []}api-version=2023-12-01likewise:{"id": null, "nextLink": null, "value": []}Previously-deployed environments with >39 functions still work, because they registered before the regression. This is empirically observable on the affected SWA (details below).
Available privately on request: subscription ID, full GitHub repo URL, deploy artifact dumps, full Oryx and Azure Functions runtime logs.
Affected SWA
Resource details (publicly observable via the custom hostnames):
ltmg-websiteLTMGuserProvidedFunctionApps, nolinkedBackends)api_locationapiapi/package.json→"main": "main.js",@azure/functions ^4.5.0)0.2.20260109.4+7d54dd5970dbbf3ae6bdcf4dee57b07816a392ce22.22.0~4Microsoft.Azure.Functions.ExtensionBundle,[4.*, 5.0.0)userProvidedFunctionAppsnulllinkedBackends[]backendRegion{ regionName: null, status: null }Reproduction
A controlled bisection was performed on a test branch (
test/minimal-api) and a fresh preview environment (build 9on the affected SWA), creating a new PR each step so the function host was never reused. Samestaticwebapp.config.json, samehost.json, sameapi/package.json. Only the count ofapp.http()calls inapi/main.jsvaried.Each handler was the same one-line stub:
Result:
app.http()count inapi/main.jsaz staticwebapp environment functions --query 'length(@)'/api/healthEach step was a fresh PR triggering a fresh preview env build to rule out function-host caching. The 39 vs 40 boundary was also reproduced on the production
defaultenvironment after the workaround was rolled back temporarily.Specifically:
main.js→ all 39 registered. Confirmed via ARM RESTGET .../functionslisting all 39 by name. Each function served real traffic (400/401/etc. based on the handler's input validation).main.js→ zero registered. ARM REST returnsvalue: []. Direct HTTP requests return the SWA's branded 404 page from the function host, not the app'sindex.html. Identical deploy artifact otherwise.The boundary holds at 39/40 with the simple test handlers above. Production handlers (body parsing, DB connections, blob clients, etc.) do not change the threshold.
Strongest single piece of evidence:
build 6vsbuild 9Two preview environments on the same SWA, deployed from the same GitHub repository, same deploy action version, same secrets, but at different times:
Same SWA. Same managed Functions runtime. Same deploy mechanism. Same
staticwebapp.config.jsonschema. Only when the build was performed differs.Snippet of the "successful" deploy log (with zero functions registered)
For the regression-affected deploy (40 functions in
main.js):No warning, no error, no hint that the function host then registered zero of the 40 functions present in the deployed bundle.
What's NOT the cause (ruled out during diagnosis)
We spent considerable time ruling these out — sharing in case other reporters waste time on the same theories:
Finished building function app with Oryx. The function app directory is copied to/bin/staticsites/<deploymentId>-swa-oryx/api/and dependencies installed (api/.oryx_prod_node_modules→api/node_modules). No errors at build phase.azure_static_web_apps_api_tokenis valid. The deploy step's polling step reportsFinished Upload. Polling on deployment.thenDeployment Complete :). There is no error surface at any stage — this is what makes the regression dangerous.*.azurestaticapps.nethost directly: it also returns 404 for every/api/*. Cloudflare'sCf-Cache-Status: DYNAMICconfirms the response is not cached.function.jsonfiles inapi/*subdirectories alongside the v4 model inmain.js. Removing all of them (git rm api/*/function.json) did not change behavior. The v4app.http()cap holds independent of v3function.jsonpresence.responseOverrides 404→/index.html 200rewrite instaticwebapp.config.json. This was making the 404 look like a 200 with HTML body, hiding the function-host response. Removing the override surfaces the real 404 but does not fix the registration failure. (Recommend other affected customers remove this override first to make diagnosis possible.)Deploy to Azure Static Web Appsworkflow onpush: main.engines.nodeinapi/package.jsonis just a warning to Oryx; the runtime version is determined by SWA. Pinning to~20produced annpm warn EBADENGINEbut the runtime kept using Node 22 and the cap remained.host.jsonconfiguration. DefaultextensionBundle: [4.*, 5.0.0)was used throughout. Addingextensions.http.routePrefix: "api"(a wrong guess on our part) broke things differently — produced URLs at/api/api/<route>— but withroutePrefixremoved the original cap behavior returned.az staticwebapp disconnect/reconnect. Cycling source control had no effect on the function host's behavior. A duplicate workflow file is auto-created on reconnect and must be cleaned up.az rest .../resetapikey), updated the GitHub secret, and observed the same registration failure on the next deploy. (Note: rotating the key without updating the GH secret causes a SEPARATE silent failure mode where the deploy upload step fails withNo matching Static Web App was found or the api key was invalidand reports overall workflow success — this is also worth surfacing as an error.)Customer impact
This is a high-severity regression because it presents as a successful deploy:
/api/*URL returns 404 for the entire time the deploy is live./api/stripe-billing), SMS-provider webhooks (/api/blooio-webhook), and other vendor webhooks 404 — no DLQ, no alert. Stripe will retry per its policy; SMS providers usually do not.Workaround (what we shipped on
ltmg-website)For other affected customers: consolidate your
app.http()calls into "router" functions that dispatch internally by URL parameter. We went from 145 registrations to 39 by adding 8 router functions:portalRouterportal-{op}careersRoutercareers-{op}proxyRouterproxy-{op}mcMidflowRoutermc-{op}opsRouterops-{op}siteRoutersite-{op}launchsiteRouterlaunchsite-{op}catchallRouter{path:regex(^(a|b|c)$)}Code sketch:
Frontend URLs are unchanged.
staticwebapp.config.jsonrole-gate rules (/api/portal-*→authenticated) still apply because the wildcard matches the URL pattern regardless of how the underlying function is implemented.Route-precedence findings (currently undocumented for v4 model)
While building the routers above, we hit two distinct route-template behaviors that are not documented in the Azure Functions HTTP trigger docs:
route: 'prefix-{op}') DO observe literal-vs-parameterized precedence. A router atroute: 'mc-{op}'does NOT cannibalize an existing literalroute: 'mc-kanban'registration. Empirically confirmed across all 7 prefix routers (28+ literal routes coexisting with eachprefix-{op}parameterized router).route: '{*path}') do NOT observe that precedence. An un-constrained{*path}catchall WILL steal literal-route URLs. We confirmed this onbuild 12(commit7ba0c0don the affected SWA): an un-constrained catchall stole/api/trackand/api/contacteven though both have literalapp.http()registrations. The fix was to add a regex constraint:route: '{path:regex(^(track|contact|...)$)}'.Both behaviors are reasonable, but the asymmetry between
{op}and{*path}is not documented and surprised us during the bisection. Recommend documenting these in the v4-model HTTP trigger reference.Asks
WARN: <N> functions in deployment exceed registration capacity; <K> registered.{op}vs{*path}vs literal routes in the v4 model HTTP trigger reference.Happy to provide additional logs, repro PR, or join a call. Resource subscription ID and other private details available on request via this issue or a private channel.