Skip to main content

Command Palette

Search for a command to run...

Networking for Developers: TLS/SSL Explained (Why HTTPS Is Slow and How to Fix It)

Updated
•9 min read•View as Markdown
S
Engineer. Creator. Optimizer. 💡 I write about dev, freelance life, self-growth, and strategies to stand out in tech & beyond.

HTTPS migration doubled your latency. Here’s why the TLS handshake often costs 200–400ms on mobile networks — and how to cut it in half.


Networking for Developers: TLS/SSL Explained (Why HTTPS Is Slow and How to Fix It)

HTTPS migration doubled your latency. Here’s why the TLS handshake often costs 200–400ms on mobile networks — and how to cut it in half.

The TLS handshake looks simple from outside — secure connection established. Inside, it’s round-trips, certificate validation, and cryptographic negotiation. Each step costs milliseconds that multiply across mobile networks.

Our API latency spiked to 2.1 seconds at P99.

I spent three days convinced it was the database. It wasn’t. CloudWatch metrics looked clean, queries were fine, application code hadn’t changed. But mobile users were timing out and I couldn’t figure out why desktop users seemed completely unaffected.

Finally ran tcpdump on the load balancer because I was out of ideas. Just watched packets go back and forth. And there it was — 400ms happening before our code even ran. The TLS handshake. I’d been staring at application logs for three days when the problem was the secure connection itself.

Why This Actually Mattered

Mobile users dropped from 82% completion to 61%. Each abandoned checkout was \(47 average order value. At roughly 18,000 checkouts monthly, that 21-point drop cost us around \)180k. I remember showing those numbers to my manager and watching his face change.

Desktop users on WiFi barely noticed the extra milliseconds. But 3G networks? 4G on a crowded cell tower? Every round-trip gets multiplied by network conditions you can’t control. Speaking of things you can’t control, networks lie to you constantly. Your dev tools show 20ms because you’re testing on gigabit ethernet, but real users on mobile see 150–300ms per round-trip.

I assumed HTTPS was just “HTTP with encryption tacked on.” That’s not how it works at all.

The Handshake I Didn’t Know About

When your browser connects to https://api.example.com, here's what actually happens:

Client Hello : Browser says “I speak TLS 1.2 and 1.3, here are 30 ciphers I understand.”

Server Hello : Server picks one. “Let’s use TLS_AES_256_GCM_SHA384, here’s my certificate signed by Let’s Encrypt.” This response can be 4KB with the full chain.

Certificate Verification : Client checks the chain back to a root CA. Validates expiry, revocation status, domain matching.

Key Exchange : Diffie-Hellman or RSA math happens. Shared secrets get established.

Finished Messages : Both sides confirm everything. Now you can finally send HTTP.

On top of the 1 RTT TCP handshake, TLS 1.2 needs roughly 2 more RTTs. So about 3×RTT total before your application code runs — around 300ms at 100ms RTT, which doesn’t sound like much until you realize that’s happening on every new connection.

I traced packets on production:

14:23:01.234 IP client > server: [SYN]              # TCP starts  
14:23:01.334 IP server > client: [SYN-ACK]          # TCP responds    
14:23:01.435 IP client > server: TLS ClientHello    # Now TLS begins  
14:23:01.535 IP server > client: TLS ServerHello    # Cipher picked  
14:23:01.636 IP client > server: TLS KeyExchange    # Keys exchanged  
14:23:01.736 IP server > client: TLS Finished       # Finally ready

In this capture — I rounded for clarity because real timestamps are messier — packets landed roughly 100ms apart. TCP handshake, then TLS 1.2’s two round-trips. Six packets before HTTP even started. This is why connection pooling exists, by the way. You pay this cost once, then reuse the connection for hundreds of requests. Actually, most people don’t think about connection reuse enough.

TLS 1.2 requires multiple round-trips before encrypted communication begins. On high-latency networks, each round-trip compounds the delay. This diagram shows why mobile users experienced 400–600ms overhead.

TLS 1.3 Fixed Everything (Almost)

TLS 1.3 collapsed the handshake to one round-trip. Protocol designers looked at TLS 1.2 and said “half of these steps are unnecessary.” Removed weak ciphers, streamlined key exchange.

Client sends ClientHello with a key share guess upfront. Server responds with everything — ServerHello, certificate, key confirmation, Finished — in one shot. One RTT. Done.

The real breakthrough is 0-RTT resumption though. After the first connection, client stores a pre-shared key. Next time? Client sends encrypted application data in the first packet. Zero round-trips. The handshake happens while your HTTP request is already in flight.

There’s a catch — 0-RTT is replay-able. Don’t use it for non-idempotent operations. GET and HEAD are fine, POST requests that change state are not. Most frameworks disable it for unsafe methods automatically but you should verify.

We enabled TLS 1.3 on our load balancers and P99 latency dropped from 2.1s to 1.4s. Just like that. Mobile completion recovered to 78%. I wish every fix was this clean.

Here’s our nginx config:

ssl_protocols TLSv1.3;                    # Only modern TLS  
ssl_prefer_server_ciphers off;            # Client picks cipher  
ssl_session_cache shared:SSL:10m;         # Resume sessions  
ssl_session_timeout 1d;                   # Keep 24 hours  
ssl_session_tickets on;                   # Fast reconnects  
  
# Force modern crypto - old clients just fail gracefully  
ssl_ciphers 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256';

Those three middle lines do the heavy lifting. ssl_session_cache enables resumption, ssl_session_tickets allows faster reconnects. Getting 0-RTT working exactly right is server-specific though—you might need ssl_early_data on; and recent OpenSSL builds. Check your docs.

Session resumption often cuts handshake latency by well over half. Like from 200ms down to maybe 30ms on typical connections. Your second request becomes almost free, latency-wise.

Certificates Kept Biting Me

Let’s Encrypt gives free certificates that expire in 90 days. That seemed annoying at first — why not one year like paid certs? But the short expiry forced us to automate, which turned out to be exactly right.

We run certbot on a cron:

# Checks daily, only renews within 30 days of expiry  
0 0 * * * certbot renew --post-hook "systemctl reload nginx"

Runs every night, renews automatically, reloads nginx gracefully. Saved us twice when I completely forgot about expiration dates. I’m still not sure why I kept forgetting.

Certificate chain size matters more than I expected. Some CAs send 12KB chains. Larger chains mean more bytes, higher chance of delays on constrained mobile links. Let’s Encrypt keeps chains under 4KB which helps.

I watched a 6KB chain come through one day:

14:45:12.100 IP server > client: TLS Cert [1460 bytes]  
14:45:12.200 IP server > client: TLS Cert [1460 bytes]  
14:45:12.300 IP server > client: TLS Cert [1460 bytes]    
14:45:12.400 IP server > client: TLS Cert [1220 bytes]

On our link, those extra fragments added 100–200ms before the client had the full chain. Switched to a shorter chain, saved that fourth packet entirely.

OCSP Validation Was Killing Us Randomly

Some requests took 800ms. Most took 150ms. No pattern I could find. Drove me crazy for two days.

Turned out to be OCSP validation. Clients check certificate revocation status by calling the CA’s OCSP responder. When that responder is slow — or worse, timing out — your TLS connections just stall. The client is sitting there waiting for revocation info and you have no visibility into it.

OCSP stapling solved it. Server fetches the OCSP response once, caches it, includes it in the handshake. Client never makes an external request.

ssl_stapling on;                          # Server handles OCSP  
ssl_stapling_verify on;                   # Verify responses  
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;

Those 800ms spikes vanished. OCSP responses cache for 24 hours, so the server makes one request daily instead of millions from clients. Networks fail in weird ways.

HTTP/2 Changed Connection Math

HTTP/1.1 needs one connection per concurrent request. Six images on a page means six TCP handshakes, six TLS handshakes. Browsers limit you to 6–8 connections per domain so you don’t overwhelm servers.

HTTP/2 multiplexes. One connection, one TLS handshake, then stream everything. All your requests share the same encrypted tunnel.

We saw TCP connection count per user drop roughly 60% after enabling HTTP/2. Traffic stayed the same — protocol just reused one TLS connection for dozens of parallel requests. The handshake cost got amortized across everything.

listen 443 ssl http2;  # That's it, one line

Required TLS 1.2 minimum and ALPN support though. Older load balancers can’t negotiate it. Test in staging because rollback is annoying.

I Wasted a Week on Timeouts

Set my client read timeout to 100ms. Most requests finished in 50ms so that seemed reasonable.

TLS handshakes started failing intermittently. Clients disconnecting mid-handshake. Certificate chains taking 120ms to arrive over cellular networks, hitting my 100ms timeout. I spent an entire week investigating “mysterious TLS failures” before I realized my timeout was shorter than the handshake itself.

I assumed TCP was reliable. It is, mostly. But latency variance isn’t a reliability problem — it’s just reality.

In our client library, the same timeout applied during handshake and HTTP transfer. Should have had separate timeouts — maybe 5 seconds for handshakes, 1 second for requests. Mobile networks add 50–300ms jitter. Your timeout has to handle the worst case you’ll actually see, not your average case.

Changed it to 5 seconds for handshakes, failures dropped to basically zero. Simple fix, but it took me forever to figure out.

Offloading TLS Helped More Than Expected

Running TLS on application servers means every instance does crypto. AES-GCM encryption, signature verification, all CPU-intensive operations.

We moved TLS termination to load balancers. App servers take plain HTTP from the internal network. Load balancer handles crypto.

CPU usage on app servers dropped 15%. Scaled down from 20 instances to 17, saved money. Load balancers barely noticed the extra work — modern hardware has AES acceleration built in.

Traffic between load balancer and app servers runs unencrypted on our internal network. We’re fine with that — network is isolated. If you need true end-to-end encryption, you’d run TLS everywhere, but the performance cost is real.

Check Your Setup Now

Your HTTPS migration probably added latency you never measured. Try these:

TLS version : openssl s_client -connect yourapi.com:443 -tls1_3 — if it fails, you're stuck on TLS 1.2.

Session resumption : Verbose curl should show SSL-Session: Reused. If it says Reused: no every time, your cache isn't configured right.

Certificate chain : openssl s_client -showcerts dumps the full chain. Count bytes.

OCSP stapling : openssl s_client -status should show OCSP Response Status: successful. If not, clients are making external validation requests that slow everything down.

Tomorrow I’ll write about certificate expiry taking down production at 3am — and how certificate transparency logs could’ve warned us two weeks earlier. That was a rough night.


Enjoyed the read? Let’s stay connected!

  • 🚀 Follow The Speed Engineer for more Rust, Go and high-performance engineering stories.
  • 💡 Like this article? Follow for daily speed-engineering benchmarks and tactics.
  • ⚡ Stay ahead in Rust and Go — follow for a fresh article every morning & night.

Your support means the world and helps me create more content you’ll love. ❤️

1 views

More from this blog

Speed Engineer (@speed-engineer) | High-Performance Systems & Rust & NextJs & SRE

20 posts