Skip to content

perf: reuse TLS sessions across connections to the same server - #4529

Merged
sidorares merged 2 commits into
masterfrom
claude/node-mysql2-issue-4521-a4c12f
Sep 6, 2026
Merged

sidorares merged 2 commits into
masterfrom
claude/node-mysql2-issue-4521-a4c12f

Conversation

@sidorares

Copy link
Copy Markdown
Owner

Closes #4521

What

Every new TLS connection ran a full handshake: certificate chain on the wire, chain verification, fresh key derivation. This keeps the session ticket issued by a successful handshake in memory and presents it on the next connection to the same server, so a pool refilling, a pool cluster, or an application opening short-lived connections skips the certificate exchange and, on TLS 1.2, one round trip. Nothing is persisted.

How

  • lib/tls_session_cache.js: a WeakMap from the ssl config object to an LRU of at most 100 peers, mirroring Node's own https.Agent default. A TlsSessionSlot is created when startTLS begins and captures everything that shaped the verification of that handshake: the ssl object identity, a snapshot of its certificate material (ca, cert, key, ciphers, passphrase, minVersion, maxVersion), host, port, rejectUnauthorized and verifyIdentity. Tickets arrive asynchronously, on TLS 1.3 after the handshake and possibly more than once, so the slot is what a late ticket is stored into, never the current state of a shared and possibly mutated ssl object.
  • lib/base/connection.js: startTLS passes the cached ticket as the session option of tls.connect and stores the newest ticket from the socket's 'session' event.

Security properties, all covered by tests:

  • A ticket is stored only after the driver accepted the peer, that is after Node's verification and after the driver's own verifyIdentity check passed. A ticket issued before that point (TLS 1.2 issues it during the handshake) is held back until then and discarded on failure.
  • A session established under a lax configuration is never resumed by a strict one: rejectUnauthorized and verifyIdentity are part of the key, and changing any certificate material invalidates every session stored for that ssl object.
  • A failed handshake, or a failed identity check, drops the cached session. A server that rejects or expired the ticket silently performs a full handshake, and the next ticket replaces the stale one.
  • Node skips checkServerIdentity on a resumed session (the peer certificate is not re-sent, getPeerCertificate() returns an empty object), so the driver's manual identity check is skipped on resumed sessions too. That check would otherwise fail with "Cert is empty"; it is safe to skip because a ticket can only be resumed under the policy that verified the identity when it was issued.
  • rejectUnauthorized stays on by default and is untouched.

Measured

100 sequential connect + SELECT 1 + end() against Docker servers on this machine, ssl: { ca, rejectUnauthorized: false }, best of 3 interleaved rounds. "Fresh" creates a new ssl object per connection (no reuse by design), "shared" reuses one object, which is what a pool does.

Server Protocol Fresh ssl object Shared ssl object
MySQL 8.3 TLS 1.3 3.5 ms wall, 1.5 ms CPU per connection 2.7 ms wall, 1.4 ms CPU per connection, 100/100 resumed
MySQL 5.7 TLS 1.2 DHE-RSA 14.9 ms wall, 4.8 ms CPU per connection 2.2 ms wall, 1.2 ms CPU per connection, 100/100 resumed

Client CPU on TLS 1.3 was within noise on this box (the secure context is still rebuilt per connection on master; the cache from #4522 removes that). The wall-clock gain on TLS 1.2 comes from the abbreviated handshake skipping the DHE exchange and one round trip.

Tests

  • test/unit/connection/test-tls-session-cache.test.mts: keying, policy isolation, material invalidation, late tickets bound to their own handshake, LRU bound.
  • test/unit/connection/test-tls-session-resumption.test.mts: runs a local tls.Server and drives startTLS directly: resumption, identity failures never resumed past, lax session never resumed by a strict config, dropped session after a failed handshake, material change. It needs a certificate chain that actually verifies, and the existing fixtures in test/fixtures/ssl/certs do not (the server certificate's subject equals its issuer, so OpenSSL reports it as self-signed at depth 0, which is why the whole suite runs with rejectUnauthorized: false). test/fixtures/ssl/chain adds a small CA + server pair valid for resumption.test, with the mkcerts.sh that produced it.
  • test/integration/connection/test-tls-session-resumption.test.mts (MYSQL_USE_TLS=1): the second pooled connection reports isSessionReused() === true, and MySQL confirms it with Ssl_sessions_reused = 1.

Verified locally: full suite with MYSQL_USE_TLS=1 on MySQL 8.3 and MariaDB 11.8 (CI images with the fixture certs), test/integration/connection on MySQL 5.7 (TLS 1.2) and on MySQL 8.3 with compression, plus npm run lint, npm run typecheck.

Docs

website/docs/documentation/ssl.mdx gains a "TLS Session Resumption" section, including the hint to reuse one ssl object across createConnection calls.

Not included

No option to turn resumption off. A rejected ticket degrades to a full handshake, so I could not find a case that needs one; it is easy to add later as an ssl option if someone asks.

Keep the session ticket issued by a successful TLS handshake in memory
and present it on later connections to the same server, so pooled and
short-lived connections skip the certificate exchange and, on TLS 1.2,
one round trip. Sessions are keyed by the ssl object and its certificate
material, host, port, rejectUnauthorized and verifyIdentity, captured
when the handshake starts, so a session established under a lax
configuration is never resumed by a strict one. A failed handshake or a
failed identity check drops the cached session.

Closes #4521
@codecov

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.89796% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.62%. Comparing base (af6aa0e) to head (658f70e).

Files with missing lines Patch % Lines
lib/base/connection.js 80.00% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4529      +/-   ##
==========================================
+ Coverage   92.53%   92.62%   +0.08%     
==========================================
  Files          93       94       +1     
  Lines       15995    16092      +97     
  Branches     2308     2337      +29     
==========================================
+ Hits        14801    14905     +104     
+ Misses       1194     1187       -7     
Flag Coverage Δ
compression-0 92.24% <94.89%> (+0.09%) ⬆️
compression-1 92.60% <94.89%> (+0.08%) ⬆️
static-parser-0 91.45% <94.89%> (+0.09%) ⬆️
static-parser-1 91.56% <94.89%> (+0.09%) ⬆️
tls-0 92.48% <94.89%> (+0.37%) ⬆️
tls-1 92.62% <94.89%> (+0.08%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sidorares
sidorares merged commit 9178c82 into master Sep 6, 2026
125 of 126 checks passed
@sidorares
sidorares deleted the claude/node-mysql2-issue-4521-a4c12f branch September 6, 2026 09:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reuse TLS sessions across pooled connections

1 participant