WebSocket Idle Disconnect Without Keepalive¶
Status: Published
Experiment completed with real data on 2026-05-04.
1. Question¶
When a WebSocket connection is established on App Service and remains idle (no messages sent), does the platform disconnect it after a timeout? What is the idle timeout duration, and does enabling the App Service WebSocket setting affect whether the upgrade is accepted?
2. Why this matters¶
App Service has a frontend idle connection timeout that applies to all HTTP connections including WebSocket upgrades. Long-lived WebSocket connections that rely on the server to send data (e.g., server-sent events, notification streams) will be disconnected if no data flows for the timeout period. Clients that do not implement application-layer keepalive (ping/pong frames) will experience silent disconnection, often manifesting as clients that stop receiving updates without an error event.
3. Customer symptom¶
"WebSocket connections drop after a few minutes with no error" or "Our real-time feature works for a while then stops without any exception" or "The WebSocket connection seems to time out — we have to reconnect."
4. Hypothesis¶
- H1: App Service accepts WebSocket upgrade requests when
webSocketsEnabled: true. WhenwebSocketsEnabled: false, the upgrade is rejected with a non-101 response. - H2: The App Service frontend imposes a ~4-minute idle timeout on WebSocket connections. A connection with no message traffic will be disconnected after approximately 240 seconds.
- H3: Standard gunicorn workers (sync worker type) do not support WebSocket upgrades — the
/wsendpoint returns 404 because gunicorn cannot handle the Upgrade header in sync mode.
5. Environment¶
| Parameter | Value |
|---|---|
| Service | Azure App Service |
| SKU / Plan | B1 |
| Region | Korea Central |
| Runtime | Python 3.11 / gunicorn (sync workers) |
| OS | Linux |
| App name | app-batch-1777849901 |
| Date tested | 2026-05-04 |
6. Variables¶
Experiment type: Configuration / Platform behavior
Controlled:
webSocketsEnabledtoggled viaaz rest PATCH .../config/web- Flask app with
flask-sockWebSocket endpoint at/ws - gunicorn with default sync worker class
Observed:
- HTTP response to WebSocket upgrade with
webSocketsEnabled=falsevstrue - Whether gunicorn sync workers handle WS upgrade
- WS endpoint availability before/after enable
7. Instrumentation¶
curl -H "Upgrade: websocket" -H "Sec-WebSocket-Key: ..."— manual WS upgrade testaz rest GET/PATCH .../config/web— check/set webSocketsEnabled- Python
websocket-clientlibrary for WS handshake test
8. Procedure¶
- Confirm
webSocketsEnabled: false(default). Test/wsendpoint with WS upgrade headers. - Add
flask-sockWS endpoint to app. Deploy. - Enable
webSocketsEnabled: true. Test WS upgrade. - Observe whether gunicorn sync workers can handle WS upgrade.
9. Expected signal¶
- H1:
webSocketsEnabled=false→ non-101 response (or connection refused upgrade). - H3: gunicorn sync workers → 404 even with
webSocketsEnabled=true(WS requires async/gevent workers).
10. Results¶
Baseline: webSocketsEnabled=false¶
```json {"webSocketsEnabled": false, "http20Enabled": true}
```text
Deploy flask-sock endpoint, enable webSocketsEnabled¶
```bash az rest PATCH .../config/web --body '{"properties":{"webSocketsEnabled":true}}' → "webSocketsEnabled": true
```text
WS upgrade test with webSocketsEnabled=true¶
```bash
curl -H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://
``text
Pythonwebsocket-client` test:
```text websocket._exceptions.WebSocketBadStatusException: Handshake status 404 NOT FOUND
```text
Key finding
The App Service frontend correctly forwarded the WebSocket upgrade request to gunicorn (response is from gunicorn, not the App Service frontend). gunicorn's default sync worker class does not support WebSocket upgrades — it returns 404 because it cannot handle the connection upgrade protocol. WebSocket requires gunicorn with --worker-class gevent or --worker-class eventlet.
/ws-info endpoint (HTTP GET)¶
```json { "upgrade_header": "none", "connection_header": "none", "note": "WebSocket requires gevent/eventlet worker - standard gunicorn does not support WS upgrade" }
```text
11. Interpretation¶
- Observed: When
webSocketsEnabled=true, the App Service frontend does forward WebSocket upgrade requests to the application. The 404 comes from gunicorn, not from the App Service layer. H1 is partially confirmed — the setting affects whether the frontend allows the upgrade, but the backend worker must also support it. - Observed: gunicorn sync workers return 404 for WebSocket upgrade requests. H3 is confirmed. To use WebSocket on App Service with Python/gunicorn, the startup command must use
--worker-class geventor--worker-class eventlet. - Not Proven: The 4-minute idle timeout (H2) could not be directly tested because a working WebSocket connection was not established (gunicorn sync worker blocked the upgrade). The 4-minute timeout is documented platform behavior.
- Inferred: The correct startup command for WebSocket-capable gunicorn:
gunicorn --worker-class gevent --bind 0.0.0.0:8000 app:app. Thegeventpackage must be included inrequirements.txt.
12. What this proves¶
- App Service forwards WebSocket upgrade requests to the application when
webSocketsEnabled=true. Observed. - gunicorn sync workers (default) cannot handle WebSocket upgrades — they return 404. Observed.
- WebSocket on Python App Service requires
geventoreventletworker class in gunicorn. Inferred from 404 behavior and gunicorn documentation.
13. What this does NOT prove¶
- The App Service idle timeout for WebSocket connections (believed to be ~240 seconds) was not directly measured in this experiment.
- Behavior with Node.js (which supports WebSocket natively via
wsorsocket.io) was not tested. - gevent-based gunicorn WebSocket behavior was not tested (would require startup command change and redeployment).
14. Support takeaway¶
When a Python App Service returns 404 for WebSocket connections despite webSocketsEnabled=true:
- The issue is in the gunicorn worker class, not App Service configuration. Default sync workers do not support WS.
- Fix: Change startup command to
gunicorn --worker-class gevent --bind 0.0.0.0:8000 app:appand addgeventtorequirements.txt. - For idle disconnection issues (connection drops after a few minutes): implement application-layer WebSocket ping/pong every 30–60 seconds to keep the connection active through the platform's idle timeout.
- Verify
webSocketsEnabled=truevia portal oraz rest GET .../config/web --query "properties.webSocketsEnabled".
15. Reproduction notes¶
```bash
APP="
Enable WebSocket¶
az rest --method PATCH \ --uri "https://management.azure.com/subscriptions/${SUB}/resourceGroups/${RG}/providers/Microsoft.Web/sites/${APP}/config/web?api-version=2022-03-01" \ --body '{"properties":{"webSocketsEnabled":true}}'
Test WS upgrade (will fail with sync gunicorn → 404)¶
curl -sv --http1.1 \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://
For working WS: startup command must use gevent¶
# az webapp config set -n $APP -g $RG \
--startup-file "gunicorn --worker-class gevent --bind 0.0.0.0:8000 app:app"¶
pip install gevent (add to requirements.txt)¶
```text