Table of Contents
- Table of Contents
- Initial Symptoms
- The Real Problem
- Debugging WebSocket Connections
- iCloud Private Relay
- Workaround
- Update
- Related Reports
At home I use Cloudflare tunnels to access my homelab remotely. This setup works great for exposing services securely without opening ports on my router, and I also use it in combination with a separate DigitalOcean Kubernetes (DOKs) cluster running Tailscale and Pangolin for other use cases. The Cloudflare tunnel provides a reliable way to access everything from anywhere, whether I'm on my home network or out and about.
I started noticing that homelab services that rely on WebSockets weren't behaving correctly. Home Assistant's add-ons, Unraid Terminal and syslog, and Proxmox were all experiencing connection issues. After investigating, I discovered Safari/iOS 26 has a critical bug where it sends CONNECT requests instead of proper WebSocket upgrade requests.
Initial Symptoms
WebSocket connections were timing out or failing to establish. The issue was isolated to Safari desktop and iOS 26 devices - Chrome, Firefox, and even Firefox on iOS worked fine. The error displayed in Safari was:
"The String did not match the expected pattern"
Server logs showed nginx errors indicating connection failures to upstream services:
Nov 15 17:05:02 Tower nginx: 2025/11/15 17:05:02 [error]
1637750#1637750: *24422 connect() to unix:/var/run/syslog.sock failed
(111: Connection refused) while connecting to upstream, client: 192.168.10.251, server: , request: "GET /webterminal/syslog/ws HTTP/1.1",
upstream: "http://unix:/var/run/syslog.sock:/ws", host: "server.example.com"
Suspecting Cloudflare Tunnel
Since the failures only occurred through the tunnel, cloudflared seemed like the obvious culprit. Let's check if the tunnel is handling WebSocket upgrades correctly:
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://tunnel.example.com/ws
The tunnel responded correctly with 101 Switching Protocols, confirming cloudflared wasn't blocking the upgrade.
Direct connections worked fine, so the issue was specific to the tunnel path.
The Real Problem
After checking Apple Discussions, I discovered Safari/iOS 26 has a critical bug where it sends HTTP/1.1 CONNECT requests instead of proper WebSocket upgrade requests.
The server logs showed:
Unsupported upgrade request.
"CONNECT /ws/socket.io/&transport=websocket HTTP/1.1" 400
Safari was using the wrong HTTP method entirely. According to the WebSocket specification (RFC 6455), WebSocket upgrades should use:
GET /ws HTTP/1.1
Connection: Upgrade
Upgrade: websocket
But Safari/iOS 26 was sending:
CONNECT /ws HTTP/1.1
This is incorrect - CONNECT is meant for establishing tunnels (typically for HTTPS proxying), not WebSocket upgrades.
Debugging WebSocket Connections
To help debug WebSocket connection issues, I had Cursor write a simple Cloudflare Worker that inspects WebSocket upgrade requests and identifies the Safari/iOS 26 bug. The worker is available at websocket-debug.jackpearce.co.uk and provides a web interface showing:
- The HTTP method used (GET vs CONNECT)
- All request headers
- Whether the request is a valid WebSocket upgrade
- Detection of the Safari/iOS 26 CONNECT bug
The worker code:
/**
* Cloudflare Worker for debugging WebSocket upgrade requests
* Helps identify Safari/iOS 26 CONNECT vs GET request issues
*/
// In-memory store for the last WebSocket attempt (per worker instance)
let lastAttempt = null;
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const path = url.pathname;
// Serve debug interface at root
if (path === '/' || path === '/debug') {
return new Response(getDebugHTML(), {
headers: {
'Content-Type': 'text/html',
},
});
}
// Handle WebSocket upgrade attempts
if (path === '/ws' || path === '/ws-debug') {
return handleWebSocketUpgrade(request);
}
// API endpoint to get request details
if (path === '/api/inspect') {
return inspectRequest(request);
}
// API endpoint to get last WebSocket attempt
if (path === '/api/last-attempt') {
return new Response(JSON.stringify(lastAttempt || { error: 'No attempt recorded' }), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('Not Found', { status: 404 });
},
};
/**
* Handle WebSocket upgrade requests and provide detailed debugging info
*/
async function handleWebSocketUpgrade(request) {
const method = request.method;
const headers = Object.fromEntries(request.headers.entries());
// Check if this is a WebSocket upgrade request
const isUpgrade = headers['upgrade']?.toLowerCase() === 'websocket';
const isConnectionUpgrade = headers['connection']?.toLowerCase().includes('upgrade');
// Detect Safari/iOS 26 bug: CONNECT instead of GET
const isSafariBug = method === 'CONNECT' && isUpgrade;
// Build debugging information
const debugInfo = {
method: method,
url: request.url,
isWebSocketUpgrade: isUpgrade && isConnectionUpgrade,
isSafariBug: isSafariBug,
requestHeaders: headers,
timestamp: new Date().toISOString(),
userAgent: headers['user-agent'] || 'Unknown',
connectionHeader: headers['connection'] || 'Not set',
upgradeHeader: headers['upgrade'] || 'Not set',
secWebSocketKey: headers['sec-websocket-key'] || 'Not set',
secWebSocketVersion: headers['sec-websocket-version'] || 'Not set',
secWebSocketProtocol: headers['sec-websocket-protocol'] || 'Not set',
secWebSocketExtensions: headers['sec-websocket-extensions'] || 'Not set',
};
// Store this attempt for retrieval via API
lastAttempt = debugInfo;
// If it's a valid WebSocket upgrade (GET method), accept it
if (isUpgrade && isConnectionUpgrade && method === 'GET') {
// Accept the WebSocket upgrade
const upgradeHeader = request.headers.get('Upgrade');
if (upgradeHeader === 'websocket') {
const { 0: client, 1: server } = new WebSocketPair();
// Accept the WebSocket connection
server.accept();
// Send debug info as first message
server.send(JSON.stringify(debugInfo));
// Close after sending info
server.close(1000, 'Debug info sent');
return new Response(null, {
status: 101,
webSocket: client,
headers: {
'Upgrade': 'websocket',
'Connection': 'Upgrade',
},
});
}
}
// Handle CONNECT method (Safari bug) - reject with error details
if (isSafariBug) {
return new Response(JSON.stringify({
...debugInfo,
error: 'CONNECT method used instead of GET for WebSocket upgrade',
status: 'rejected',
}, null, 2), {
status: 400,
headers: {
'Content-Type': 'application/json',
'X-WebSocket-Status': 'Safari/iOS 26 Bug Detected',
'X-Request-Method': method,
'X-Error': 'CONNECT method used instead of GET for WebSocket upgrade',
},
});
}
// Return debug info for any other request
return new Response(JSON.stringify(debugInfo, null, 2), {
status: 200,
headers: {
'Content-Type': 'application/json',
'X-WebSocket-Status': 'Not a WebSocket Upgrade',
'X-Request-Method': method,
},
});
}
/**
* Inspect any request and return detailed information
*/
function inspectRequest(request) {
const headers = Object.fromEntries(request.headers.entries());
const info = {
method: request.method,
url: request.url,
headers: headers,
timestamp: new Date().toISOString(),
cf: request.cf || {},
};
return new Response(JSON.stringify(info, null, 2), {
headers: {
'Content-Type': 'application/json',
},
});
}
/**
* HTML interface for debugging WebSocket connections
*/
function getDebugHTML() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket Debug Tool</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
padding: 20px;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #4a9eff;
margin-bottom: 10px;
}
.subtitle {
color: #888;
margin-bottom: 30px;
}
.section {
background: #2a2a2a;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #3a3a3a;
}
.section h2 {
color: #4a9eff;
margin-bottom: 15px;
font-size: 1.2em;
}
button {
background: #4a9eff;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover {
background: #5aaeff;
}
button:disabled {
background: #555;
cursor: not-allowed;
}
.status {
padding: 12px;
border-radius: 6px;
margin-top: 15px;
font-weight: 500;
}
.status.success {
background: #2d5a2d;
color: #90ee90;
}
.status.error {
background: #5a2d2d;
color: #ff6b6b;
}
.status.warning {
background: #5a4d2d;
color: #ffd93d;
}
.status.info {
background: #2d3a5a;
color: #6b9fff;
}
pre {
background: #1a1a1a;
padding: 15px;
border-radius: 6px;
overflow-x: auto;
border: 1px solid #3a3a3a;
font-size: 13px;
line-height: 1.5;
}
code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.header-grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: 10px;
margin-top: 10px;
}
.header-key {
color: #888;
font-weight: 500;
}
.header-value {
color: #e0e0e0;
word-break: break-all;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-left: 10px;
}
.badge.success {
background: #2d5a2d;
color: #90ee90;
}
.badge.error {
background: #5a2d2d;
color: #ff6b6b;
}
.badge.warning {
background: #5a4d2d;
color: #ffd93d;
}
</style>
</head>
<body>
<div class="container">
<h1>WebSocket Debug Tool</h1>
<p class="subtitle">Debug WebSocket upgrade requests and detect Safari/iOS 26 CONNECT vs GET issues</p>
<div class="section">
<h2>WebSocket Connection Status</h2>
<div id="testStatus">
<p style="color: #888;">Connecting...</p>
</div>
</div>
<div class="section">
<h2>Connection Details</h2>
<div id="connectionDetails">
<p style="color: #888;">Connecting...</p>
</div>
</div>
<div class="section">
<h2>Request Headers</h2>
<div id="requestHeaders">
<p style="color: #888;">No request data yet</p>
</div>
</div>
<div class="section">
<h2>Response Headers</h2>
<div id="responseHeaders">
<p style="color: #888;">No response data yet</p>
</div>
</div>
<div class="section">
<h2>Raw Response</h2>
<pre id="rawResponse"><code>No data yet</code></pre>
</div>
</div>
<script>
const baseUrl = window.location.origin;
const wsUrl = baseUrl.replace('http', 'ws') + '/ws-debug';
let connectionStartTime = Date.now();
let requestDetails = null;
let responseDetails = null;
function updateStatus(message, type = 'info') {
const statusDiv = document.getElementById('testStatus');
statusDiv.innerHTML = \`<div class="status \${type}">\${message}</div>\`;
}
function displayConnectionDetails(data) {
if (!data) return;
const detailsDiv = document.getElementById('connectionDetails');
const isBug = data.isSafariBug;
const isValid = data.isWebSocketUpgrade && data.method === 'GET';
let statusBadge = '';
if (isBug) {
statusBadge = '<span class="badge error">Safari/iOS 26 Bug Detected</span>';
} else if (isValid) {
statusBadge = '<span class="badge success">Valid WebSocket Upgrade</span>';
} else {
statusBadge = '<span class="badge warning">Not a WebSocket Upgrade</span>';
}
detailsDiv.innerHTML = \`
<div class="header-grid">
<div class="header-key">Method:</div>
<div class="header-value"><strong>\${data.method}</strong> \${statusBadge}</div>
<div class="header-key">URL:</div>
<div class="header-value">\${data.url}</div>
<div class="header-key">User Agent:</div>
<div class="header-value">\${data.userAgent}</div>
<div class="header-key">Timestamp:</div>
<div class="header-value">\${data.timestamp}</div>
<div class="header-key">Is WebSocket Upgrade:</div>
<div class="header-value">\${data.isWebSocketUpgrade ? 'Yes' : 'No'}</div>
<div class="header-key">Safari Bug Detected:</div>
<div class="header-value">\${data.isSafariBug ? 'Yes - CONNECT used instead of GET' : 'No'}</div>
</div>
\`;
}
function displayRequestHeaders(headers) {
if (!headers) return;
const headersDiv = document.getElementById('requestHeaders');
let html = '<div class="header-grid">';
for (const [key, value] of Object.entries(headers)) {
html += \`
<div class="header-key">\${key}:</div>
<div class="header-value">\${value}</div>
\`;
}
html += '</div>';
headersDiv.innerHTML = html;
}
function displayResponseHeaders(headers) {
if (!headers) return;
const headersDiv = document.getElementById('responseHeaders');
let html = '<div class="header-grid">';
for (const [key, value] of Object.entries(headers)) {
html += \`
<div class="header-key">\${key}:</div>
<div class="header-value">\${value}</div>
\`;
}
html += '</div>';
headersDiv.innerHTML = html;
}
function displayRawResponse(data) {
const rawDiv = document.getElementById('rawResponse');
if (data) {
rawDiv.innerHTML = \`<code>\${JSON.stringify(data, null, 2)}</code>\`;
}
}
// Automatically attempt WebSocket connection on page load
updateStatus('Attempting WebSocket connection...', 'info');
const ws = new WebSocket(wsUrl);
ws.onopen = (event) => {
const duration = Date.now() - connectionStartTime;
updateStatus(\`✅ WebSocket connection opened successfully (took \${duration}ms)\`, 'success');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
requestDetails = data;
displayConnectionDetails(data);
displayRequestHeaders(data.requestHeaders);
displayRawResponse(data);
// Extract response headers if available
if (data.responseHeaders) {
displayResponseHeaders(data.responseHeaders);
}
} catch (err) {
console.error('Failed to parse WebSocket message:', err);
}
};
ws.onerror = (error) => {
updateStatus('❌ WebSocket connection failed', 'error');
// Poll for the last attempt details (the worker stores them)
const pollAttempt = () => {
fetch(baseUrl + '/api/last-attempt')
.then(res => res.json())
.then(data => {
if (data && !data.error) {
requestDetails = data;
displayConnectionDetails(data);
displayRequestHeaders(data.requestHeaders);
displayRawResponse(data);
if (data.isSafariBug) {
updateStatus('⚠️ Safari/iOS 26 Bug Detected: CONNECT method used instead of GET', 'error');
} else {
updateStatus('❌ WebSocket connection failed - see details below', 'error');
}
} else {
// Retry once after a short delay
setTimeout(pollAttempt, 100);
}
})
.catch(err => {
console.error('Failed to fetch attempt details:', err);
});
};
// Start polling after a short delay to let the worker process the request
setTimeout(pollAttempt, 50);
};
ws.onclose = (event) => {
const duration = Date.now() - connectionStartTime;
let message = \`WebSocket closed: Code \${event.code}\`;
if (event.reason) {
message += \`, Reason: \${event.reason}\`;
}
message += \` (duration: \${duration}ms)\`;
if (event.code === 1000) {
updateStatus(message, 'success');
} else if (event.code === 1006) {
updateStatus(\`❌ \${message} - Connection closed abnormally (likely rejected by server)\`, 'error');
} else {
updateStatus(message, 'warning');
}
};
</script>
</body>
</html>`;
}
iCloud Private Relay
This issue is only apparent when using Safari with iCloud Private Relay enabled. Safari without Private Relay works correctly and establishes WebSocket connections without problems.
How iCloud Private Relay Works
iCloud Private Relay is a privacy feature that routes your web traffic through two separate, secure internet relays. When enabled, your requests are sent through Apple's first relay (which can see your IP address but not the website you're visiting due to encrypted DNS), then through a second relay operated by a third-party content provider (which generates a temporary IP address and connects you to the site). This dual-relay system ensures no single party can see both who you are and what sites you're visiting.
When Safari with Private Relay attempts to establish WebSocket connections through reverse proxies (Cloudflare tunnel, Caddy, nginx, etc.), the malformed CONNECT request gets rejected by the backend server with 400 Bad Request.
Workaround
There is currently no server-side fix for this Safari/iOS 26 bug. Affected users & developers have several options:
- Use another browser: Chrome, Firefox, and other browsers that don't make use of iCloud Private Relay work fine
- Turn off iCloud Private Relay: Disabling iCloud Private Relay in Safari/iOS settings will allow WebSocket connections to work correctly
- Use a VPN: Running Wireguard on demand when not on home WiFi can bypass the issue (this is what I'm currently doing)
- Configure a reverse proxy to force HTTP/1.1: Setting up a reverse proxy and forcing HTTP/1.1 instead of HTTP/2 can work around the issue by preventing the malformed CONNECT request behavior
Update
As of macOS 26.2 / Safari 26.2, I'm seeing improved behavior. The Unraid console now works and I can see a WebSocket connection opened, but there's a significant delay of 5-6 seconds before the connection establishes.
To investigate this delay, I saved a HAR file from my browser and asked Claude to inspect it. Claude wrote a Python script to analyze the HAR, but the results were inconclusive:
"This HAR file only captured 2 HTTP requests, but ttyd (the web terminal) requires a WebSocket connection to function. That 7.7 second gap is almost certainly the WebSocket connection establishment time, which is very long.
Why the WebSocket might not be visible:
Safari's Web Inspector sometimes doesn't capture WebSocket handshakes in HAR exports the same way Chrome does. The WebSocket upgrade request (which would show as an HTTP request that switches protocols) isn't in this HAR."
Related Reports
This Safari/iOS 26 WebSocket bug has been reported across multiple platforms and services:
- Figma Forum: Safari macOS 26 sometimes not loading websockets
- Render Community: WebSocket error only on iOS Safari
- Chess.com Forum: Mac Safari 26.0 conflict with chess.com
These reports confirm the issue is widespread and affects many different services using WebSockets with Safari/iOS 26.
Have you experienced this issue? Comment below.