Skip to content

wss vs ws: Secure WebSocket vs Unencrypted Explained

ws://wss://
EncryptionNoneTLS (same as HTTPS)
Default port80443
Works on HTTPS pagesNo (mixed content)Yes
Passes through proxiesOften stripped or blockedYes (encrypted tunnel)
Production useLocal development onlyRequired

Encryption is reason enough. But two practical issues make ws:// unusable in production even if security is not your primary concern.

If your page is served over HTTPS — and every production page should be — browsers refuse to open ws:// connections. This is the same mixed content policy that blocks HTTP images on HTTPS pages. The connection fails silently. No error dialog. No user prompt. Just a rejected WebSocket in the console.

Mixed Content: The page was loaded over HTTPS, but attempted
to connect to the insecure WebSocket endpoint 'ws://...'.
This request has been blocked.

There is no workaround. No header, no flag, no user override. If your page is HTTPS, your WebSocket must be wss://.

Corporate proxies, transparent proxies, and ISP-level middleboxes inspect unencrypted traffic. They often don’t understand the WebSocket upgrade handshake and either drop the connection or strip the Upgrade header. The result: your WebSocket works on your home network and fails silently for users behind corporate firewalls.

wss:// solves this because the TLS tunnel is opaque to intermediaries. They see an HTTPS connection to port 443 and pass it through. This is the same reason services like Ably, Pusher, and PubNub exclusively use wss:// for client connections — ws:// is simply unreliable across real-world networks.

The performance argument against TLS died years ago.

The TLS 1.3 handshake adds one round trip — roughly 1-2ms on modern hardware. After the handshake, per-frame encryption uses AES-GCM with hardware acceleration (AES-NI). The overhead is microseconds per frame, not milliseconds.

For context: a WebSocket frame header is 2-14 bytes. TLS adds roughly 29 bytes of overhead per record (21 bytes for TLS 1.2, fewer for TLS 1.3). On a 100-byte message, that is a 29% size increase. On a 1KB message, it is under 3%. The CPU cost of encrypting either is unmeasurable in a flame graph.

WebSocket connections are long-lived. You pay the handshake cost once, then send thousands of frames over the same connection. The amortized TLS cost per message is effectively zero.

Browsers handle ws:// and wss:// differently in ways that matter for debugging:

Certificate errors are silent. When an HTTPS page loads an image from a server with a bad certificate, the browser shows a warning. When a wss:// connection fails due to a certificate error, the browser fires a generic onerror event with no details. The CloseEvent.code is typically 1006 (abnormal closure) with an empty reason string. You get no indication that the certificate was the problem.

No certificate override. HTTPS pages with invalid certificates show a “proceed anyway” button. WebSocket connections do not. A bad certificate means no connection, period.

DevTools visibility. Chrome and Firefox show WebSocket frames in the Network tab, but only if you select the connection before messages start flowing. There is no retroactive capture. This applies to both ws:// and wss://, but with wss:// you also cannot use Wireshark to inspect traffic as a fallback.

Most production deployments do not terminate TLS in the application. The standard pattern:

Client (wss://) --> Load Balancer (TLS termination)
--> Backend (ws://)

Your load balancer or reverse proxy handles the certificate and encryption. Your application server receives plain ws:// connections on an internal network. This is simpler, performs better, and centralizes certificate management.

Nginx terminates TLS and proxies ws:// to the backend. The key lines: listen 443 ssl for the TLS listener, proxy_http_version 1.1 (HTTP/1.0 doesn’t support upgrades), and the Upgrade / Connection header forwarding. Set proxy_read_timeout to at least 24 hours — Nginx defaults to 60 seconds and will drop idle WebSocket connections. See the Nginx WebSocket configuration guide for a full production config with SSL, health checks, and upstream tuning.

  • AWS ALB — terminates TLS with ACM certificates, forwards WebSocket connections to target groups on port 80
  • Cloudflare — terminates TLS at the edge, proxies to your origin over ws:// or wss://
  • HAProxy — TLS termination with WebSocket-aware connection handling and health checks

Use Let’s Encrypt with Certbot for free, automated TLS certificates. Certificates renew every 90 days. Certbot handles renewal automatically.

Terminal window
# Install certbot and get a certificate
sudo certbot --nginx -d example.com
# Verify auto-renewal works
sudo certbot renew --dry-run

If you terminate TLS at a cloud load balancer (AWS ALB, Cloudflare), use their built-in certificate management instead. AWS Certificate Manager is free for ALB-attached certificates.

Browsers silently reject WebSocket connections to servers with self-signed certificates. Unlike HTTPS pages, there is no “click to proceed anyway” dialog. The connection fails with a generic error and no explanation.

Use mkcert to create locally-trusted development certificates:

Terminal window
# Install mkcert (macOS)
brew install mkcert
mkcert -install
# Create certificates for localhost
mkcert localhost 127.0.0.1 ::1
# Creates localhost+2.pem and localhost+2-key.pem

Then use these certificates in your development server. Node.js example:

const https = require('https');
const fs = require('fs');
const { WebSocketServer } = require('ws');
const server = https.createServer({
cert: fs.readFileSync('localhost+2.pem'),
key: fs.readFileSync('localhost+2-key.pem'),
});
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
ws.on('message', (data) => ws.send(data));
});
server.listen(8443);

This is the most common WebSocket bug. A developer uses ws:// during development, it works on localhost, and the hardcoded URL ships to production. It fails on every HTTPS page.

Match the page protocol instead:

const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/ws`);

Your TLS certificate must match the hostname in the wss:// URL. If your certificate is for example.com but your client connects to wss://api.example.com, the connection fails silently. The browser fires onerror with no useful details.

Check with OpenSSL:

Terminal window
openssl s_client -connect api.example.com:443 \
-servername api.example.com < /dev/null 2>&1 \
| openssl x509 -noout -text | grep -A1 "Subject Alternative Name"

If the hostname is not in the SAN list, the certificate will not work for WebSocket connections to that hostname.

Nginx defaults to HTTP/1.0 for upstream connections. HTTP/1.0 does not support the Upgrade mechanism. Your WebSocket handshake will fail with a 400 Bad Request from the backend. Always set proxy_http_version 1.1 in your WebSocket location block.

What is the difference between ws and wss?

Section titled “What is the difference between ws and wss?”

ws:// is unencrypted WebSocket on port 80. wss:// is WebSocket over TLS on port 443. The framing format and message semantics are identical — the only difference is whether the TCP connection is wrapped in TLS. Use wss:// for everything except localhost during development.

Yes. Beyond encryption, wss:// is required for compatibility. Browsers block ws:// from HTTPS pages (mixed content policy), and network intermediaries regularly interfere with unencrypted WebSocket traffic. The only environment where ws:// works reliably is localhost.

The TLS 1.3 handshake adds one round trip — roughly 1-2ms on modern hardware. After that, per-frame encryption adds microseconds with hardware-accelerated AES. WebSocket connections are long-lived, so the handshake cost is paid once and amortized across thousands of messages. TLS latency will not appear in your performance budget.

Can browsers connect to ws:// from an HTTPS page?

Section titled “Can browsers connect to ws:// from an HTTPS page?”

No. Every modern browser enforces mixed content blocking. An HTTPS page cannot open a ws:// connection. Chrome, Firefox, Safari, and Edge all block it silently — no prompt, no override, no workaround. The only fix is to use wss://. This has been enforced since Chrome 61 (2017) and is now universal.

How do I set up TLS for WebSocket in production?

Section titled “How do I set up TLS for WebSocket in production?”

Terminate TLS at your load balancer or reverse proxy, not in your application. Use Let’s Encrypt with Certbot for free certificates, or use your cloud provider’s certificate management (AWS ACM, Cloudflare). Proxy plain ws:// to your backend over the internal network. See the Nginx configuration above for a working example.