How much does HTTPS actually cost you?

We measured plain HTTP against HTTPS on Node 23 over loopback, where there is no network to hide behind. A full TLS 1.3 handshake costs 0.20 ms of server CPU, steady-state throughput costs 23% on small responses, and session resumption — the thing everyone reaches for — saved nothing measurable.

The internet reaching services through one gate

A full TLS 1.3 handshake cost 0.20 ms of server CPU, and once the connection was up, HTTPS delivered 77% of plain HTTP's request rate on small responses. That is the whole bill on an Apple M3. The folklore that "TLS is slow" is correctly priced for 2010, when certificates were RSA-2048 and hardware AES was new. Both of those things changed.

The measurement that surprised us was session resumption. It is the standard advice for making TLS cheap, and on a TLS 1.3 connection with an ECDSA certificate it made the handshake 0.02 ms slower, reproducibly, across six alternating passes of 500 connections. It is still worth having — but for a reason that has nothing to do with the case most people assume.

The short answer

  • A full TLS 1.3 handshake costs 0.20 ms of server CPU with an ECDSA P-256 certificate — about 5,000 new connections per second per core. The same handshake with an RSA-2048 certificate costs 0.60 ms, three times as much.
  • Steady-state HTTPS costs 23% of throughput on 1 KiB responses: 48,094 requests/sec plain against 39,596 over TLS, or 0.073 ms against 0.083 ms per request. The encryption is 0.010 ms of that.
  • Bulk encryption costs 234 ms of server CPU per GiB. Saturating a gigabit link with TLS therefore costs about 2.7% of one core. It is not free, but it is not a number you will ever notice.
  • Session resumption saved nothing on TLS 1.3 with an ECDSA certificate (0.617 ms full, 0.639 ms resumed). With an RSA-2048 certificate it cut server CPU per handshake from 0.596 ms to 0.160 ms — 3.7x. Resumption is worth exactly as much as your certificate's signature is expensive.
  • Reusing the connection matters 19x more than any of it. Same server, same payload: 35,815 req/sec on keep-alive connections, 1,879 req/sec when every request opened a new one.

What was measured, and on what

Apple M3, 4 performance and 4 efficiency cores, 16 GB RAM, macOS 26.4.1 (build 25E253). Node v23.5.0, using only node:http, node:https and node:tls, with Node's bundled OpenSSL 3.0.15+quic. Certificates are self-signed for CN=localhost, generated by the OpenSSL 3.6.3 command line: one ECDSA P-256 and one RSA-2048.

Five servers, one handler, one payload, differing only in transport:

Port Transport Certificate Negotiated cipher
55621 plain HTTP
55622 HTTPS, TLS 1.3 only ECDSA P-256 TLS_AES_256_GCM_SHA384
55623 HTTPS, TLS 1.2 only ECDSA P-256 ECDHE-ECDSA-AES128-GCM-SHA256
55624 HTTPS, TLS 1.3 only RSA-2048 TLS_AES_256_GCM_SHA384
55625 HTTPS, TLS 1.3, tickets off ECDSA P-256 TLS_AES_256_GCM_SHA384

Everything runs over loopback, and that is a deliberate distortion. There is no network, so there are no round trips, so what is left is pure CPU. That makes these numbers a clean measure of the one thing people argue about — and it makes them a severe understatement of what a handshake costs in production, where the dominant term is not arithmetic but latency. A full TLS 1.3 handshake adds one round trip on top of the TCP handshake; TLS 1.2 adds two. Against a client 30 ms away, that round trip costs 30 ms while the crypto costs 0.6 ms. The CPU is 2% of the bill. Read every handshake number below as the part of the cost your CPU is responsible for, and add your own RTT.

Two more caveats. Client and server are separate single-threaded Node processes on the same laptop, so a load figure is a per-core figure and the client's own crypto competes for the same cores. And macOS moves processes between performance and efficiency cores, which swung absolute throughput by up to 2x between windows. Every comparison below therefore runs as adjacent pairs with the order reversed on alternate passes, reporting the median of the per-pair ratios rather than the ratio of the medians.

TLS costs little when connections are reused and a great deal when they are not

Does TLS slow down throughput?

Yes, measurably, and by less than the argument it generates would suggest. Four keep-alive connections, 6-second runs, nine alternating pairs:

Response size Plain HTTP HTTPS (TLS 1.3) HTTPS as % of HTTP Latency p50
1 KiB 48,094 req/s 39,596 req/s 77% 0.073 → 0.083 ms
1 MiB 4,738 MiB/s 2,127 MiB/s 45% 0.674 → 1.431 ms

The per-pair ratio was 0.771 for the small body, spread 0.732 to 0.986 across nine pairs, and 0.450 for the large one, spread 0.400 to 0.534.

That second row looks alarming until you read the units. 2,127 MiB/s is 17.8 gigabits per second of encrypted HTTP out of a single Node thread. The 55% "loss" is headroom that exists only because loopback has no wire to be the bottleneck. On a real link, the wire runs out long before the cipher does.

To separate the server's cost from the client's, we read the server process's own process.cpuUsage() before and after transferring 64 MiB bodies over one reused connection, five passes each:

Transport Throughput Server CPU per GiB
plain HTTP 3,669 MiB/s 140 ms
HTTPS, TLS 1.3, AES-256-GCM 1,357 MiB/s 374 ms
HTTPS, TLS 1.2, AES-128-GCM 1,374 MiB/s 373 ms

Encryption costs 234 ms of server CPU per GiB. That is the honest steady-state number, and the one to do arithmetic with. A gigabit link carries 0.116 GiB/s, so saturating it in TLS costs 27 ms of CPU per second — 2.7% of one core. Ten gigabits costs 27%. For scale, openssl speed reports AES-256-GCM at 8.35 GB/s per core on this machine; record framing and copies put a real protocol at roughly half the raw cipher rate.

How expensive is the TLS handshake?

This is where the money is, and it is still not much money. Serial connections, 200 per run, five alternating passes. The wall-clock column is client-observed and includes both sides' work; the CPU column is the server process alone, over 600 handshakes per run:

Connection Wall clock (p50) Server CPU Handshakes/sec/core
TCP only, no TLS 0.078 ms
TLS 1.3, ECDSA P-256, full 0.627 ms 0.200 ms 5,000
TLS 1.3, ECDSA P-256, resumed 0.651 ms 0.167 ms 5,988
TLS 1.2, ECDSA P-256, full 0.711 ms 0.226 ms 4,425
TLS 1.2, ECDSA P-256, resumed 0.497 ms 0.093 ms 10,753
TLS 1.3, RSA-2048, full 0.993 ms 0.596 ms 1,678
TLS 1.3, RSA-2048, resumed 0.634 ms 0.160 ms 6,250

Pass-to-pass spread was tight: the TLS 1.3 ECDSA full handshake ranged 0.611 to 0.639 ms, RSA 0.991 to 1.053 ms.

The single biggest lever in that table is the certificate key type. Swapping ECDSA P-256 for RSA-2048 triples the server's CPU cost per handshake, from 0.200 ms to 0.596 ms. The difference is 0.396 ms, and openssl speed on this machine prices one RSA-2048 signature at 0.422 ms against 0.0132 ms for an ECDSA P-256 signature. The gap in the handshake is the signature, almost exactly.

Now put the handshake in context. Same server, same 1 KiB response, changing only whether the client reuses its connection:

Client behaviour Requests/sec p50 latency
HTTPS, keep-alive 35,815 0.094 ms
plain HTTP, new connection per request 13,744 0.241 ms
HTTPS, new connection per request 1,879 1.962 ms

Connection reuse is worth 19x. TLS is worth 23%. If you are optimising HTTPS, and your client opens a new connection per request, the TLS is not your problem — the same mistake that makes a Postgres connection cost 59x the query it runs is at work here, one layer up.

Does session resumption make TLS cheaper?

Not the way it is usually described, and this is the result we did not expect.

On TLS 1.3 with an ECDSA certificate, the resumed handshake was slower than the full one — 0.639 ms against 0.617 ms — over six alternating passes of 500 connections each, with the ranges not overlapping (full 0.589–0.617, resumed 0.638–0.644). Every resumption was verified with socket.isSessionReused(), 300 out of 300, so this is not resumption silently failing.

The reason is arithmetic. What resumption removes from a TLS 1.3 handshake is the certificate, the server's signature over it, and the client's verification — which openssl speed prices here at 0.0132 ms plus 0.0386 ms, about 0.05 ms together. What it adds is issuing, encrypting and decrypting a session ticket, plus Node's own session plumbing, and a TLS 1.3 resumption still performs a fresh key exchange by default. The two sides of the ledger cancel.

Change the certificate and the ledger changes completely. With RSA-2048, resumption cut server CPU per handshake from 0.596 ms to 0.160 ms — a 3.7x reduction, because now there is a 0.42 ms signature to skip. Under load, that showed up exactly where you would predict, in a connect-per-request run:

Server Full handshake Resumed Gain
TLS 1.3, ECDSA P-256 1,879 req/s 1,705 req/s none
TLS 1.2, ECDSA P-256 1,569 req/s 1,919 req/s 1.22x
TLS 1.3, RSA-2048 1,207 req/s 2,000 req/s 1.66x

Session resumption is worth exactly as much as your certificate's signature is expensive. With a modern ECDSA certificate on TLS 1.3, the CPU saving is nothing. Keep it anyway — it also removes a network round trip on TLS 1.2, and it is what makes 0-RTT possible — but do not expect it on a CPU graph.

The control server on 55625, which refuses to issue tickets, confirms the mechanism from the other side: a client that offers a session and is refused pays 0.763 ms instead of 0.618 ms. Broken resumption is worse than none.

Is TLS 1.3 actually faster than TLS 1.2?

On CPU alone, yes, but only for full handshakes: 0.627 ms against 0.711 ms, and 0.200 ms of server CPU against 0.226 ms. For resumed handshakes, TLS 1.2 wins on this box — 0.497 ms against 0.651 ms — because its abbreviated handshake skips the key exchange that TLS 1.3 still performs.

That inversion is a loopback artefact, and the clearest illustration of why these numbers need the network added back. TLS 1.3's real advantage is that it completes in one round trip instead of two: over a 30 ms link that is worth 30 ms, against the 0.15 ms of CPU it gives back on resumption. Use TLS 1.3 — just not for CPU reasons.

Steady-state throughput was indistinguishable between the versions: 374 ms of CPU per GiB for AES-256-GCM on TLS 1.3, 373 ms for AES-128-GCM on TLS 1.2. Hardware AES makes the larger key free.

So when does TLS actually cost you anything?

Three situations, in order of how often they are the real answer:

  • Clients that do not reuse connections. 19x. Nothing else on this page is close. Fix keep-alive before you look at anything else.
  • An RSA certificate on a connection-churning workload. 1,678 handshakes per core per second against 5,000 for ECDSA. If you terminate a lot of new TLS connections, the key type is the one setting worth changing.
  • Bulk transfer above about 10 Gbps per core. At 234 ms of CPU per GiB, ten gigabits costs 27% of a core. Below that, it rounds to nothing.

Everything else is folklore. The steady-state cost of HTTPS on a 1 KiB response is 0.010 ms. If your handler takes a millisecond, TLS is 1% of it. If it touches a database, TLS is a rounding error. And when a reverse proxy handles certificates for you in one line of config, the cost of not having HTTPS is now higher than the cost of having it.

Check it yourself

One certificate and one file, about twenty seconds. No network, no Docker.

mkdir tls-cost && cd tls-cost
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
  -keyout key.pem -out cert.pem -days 30 -nodes \
  -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost"

Save this as check.mjs. It starts a plain server on 55621 and a TLS server on 55622 with the same handler, then measures request rate, bulk throughput, and handshake cost fresh against resumed:

import http from 'node:http';
import https from 'node:https';
import tls from 'node:tls';
import fs from 'node:fs';

const cert = { key: fs.readFileSync('key.pem'), cert: fs.readFileSync('cert.pem') };
const SMALL = Buffer.alloc(1024, 0x78), HUGE = Buffer.alloc(64 << 20, 0x79);
const handler = (req, res) => {
  const b = req.url === '/huge' ? HUGE : SMALL;
  res.writeHead(200, { 'content-length': b.length });
  res.end(b);
};
const plain = http.createServer(handler).listen(55621, '127.0.0.1');
const secure = https.createServer(cert, handler).listen(55622, '127.0.0.1');

const get = (mod, port, path, agent) => new Promise((ok, no) => {
  const r = mod.request({ host: '127.0.0.1', port, path, agent, rejectUnauthorized: false,
    servername: 'localhost' }, (res) => {
    let n = 0; res.on('data', (c) => { n += c.length; }); res.on('end', () => ok(n));
  });
  r.on('error', no); r.end();
});

async function load(mod, port, path, ms) {                     // one reused connection
  const agent = new mod.Agent({ keepAlive: true, maxSockets: 1, rejectUnauthorized: false });
  await get(mod, port, path, agent);                            // warm up + handshake
  const t = Date.now(); let n = 0, bytes = 0;
  while (Date.now() - t < ms) { bytes += await get(mod, port, path, agent); n++; }
  const s = (Date.now() - t) / 1000; agent.destroy();
  return { rps: Math.round(n / s), mibs: Math.round(bytes / 1048576 / s) };
}

function handshake(n, session) {                                // serial, one at a time
  return new Promise((done) => {
    const out = []; const sess = session; let reused = 0;
    (function next(i) {
      if (i === n) {
        out.sort((a, b) => a - b);
        return done({ p50: +out[out.length >> 1].toFixed(3), reused });
      }
      const t = process.hrtime.bigint();
      const s = tls.connect({ port: 55622, host: '127.0.0.1', servername: 'localhost',
        rejectUnauthorized: false, ...(sess ? { session: sess } : {}) }, () => {
        if (i >= 20) {                                          // skip warm-up
          out.push(Number(process.hrtime.bigint() - t) / 1e6);
          if (s.isSessionReused()) reused++;
        }
        s.destroy(); setImmediate(() => next(i + 1));
      });
    })(0);
  });
}

// TLS 1.3 sends the session ticket AFTER the handshake, so wait for the event.
const ticket = await new Promise((ok) => {
  const s = tls.connect({ port: 55622, host: '127.0.0.1', servername: 'localhost',
    rejectUnauthorized: false }, () => {});
  s.once('session', (x) => { s.destroy(); ok(x); });
});

const h1 = await load(http, 55621, '/', 3000);
const h2 = await load(https, 55622, '/', 3000);
const b1 = await load(http, 55621, '/huge', 3000);
const b2 = await load(https, 55622, '/huge', 3000);
const full = await handshake(320, null);
const res = await handshake(320, ticket);

console.log(`1 KiB, keep-alive   http ${h1.rps} rps   https ${h2.rps} rps` +
            `   (TLS costs ${(100 - 100 * h2.rps / h1.rps).toFixed(0)}%)`);
console.log(`64 MiB bulk         http ${b1.mibs} MiB/s  https ${b2.mibs} MiB/s` +
            `  (TLS costs ${(100 - 100 * b2.mibs / b1.mibs).toFixed(0)}%)`);
console.log(`handshake p50       full ${full.p50} ms   resumed ${res.p50} ms` +
            `   (${res.reused}/300 actually resumed)`);
plain.close(); secure.close();
node check.mjs

Three consecutive runs on the machine described above:

1 KiB, keep-alive   http 30376 rps   https 23546 rps   (TLS costs 22%)
64 MiB bulk         http 3728 MiB/s  https 1360 MiB/s  (TLS costs 64%)
handshake p50       full 0.591 ms   resumed 0.607 ms   (300/300 actually resumed)

1 KiB, keep-alive   http 30658 rps   https 23815 rps   (TLS costs 22%)
64 MiB bulk         http 3663 MiB/s  https 1358 MiB/s  (TLS costs 63%)
handshake p50       full 0.595 ms   resumed 0.612 ms   (300/300 actually resumed)

1 KiB, keep-alive   http 30523 rps   https 23893 rps   (TLS costs 22%)
64 MiB bulk         http 3681 MiB/s  https 1357 MiB/s  (TLS costs 63%)
handshake p50       full 0.592 ms   resumed 0.608 ms   (300/300 actually resumed)

Note the last line every time: resumption is not broken, it just does not help here. Swap the openssl req line for -newkey rsa:2048, regenerate, and run it again — the full handshake roughly doubles and resumption suddenly earns its reputation. That one substitution is the whole article.

One thing that broke while measuring: at a few thousand serial connections per run, repeated, the client died with EADDRNOTAVAIL. macOS hands out ephemeral ports from 49152 to 65535, and sockets in TIME_WAIT retire more slowly than a tight loop consumes them. Connection churn breaks your load generator before it stresses the server.