HTTP/2 Multiplexing with libcurl in C++

We recently rewrote a piece of C++ that sends a few hundred small HTTPS requests at a time: DNS-over-HTTPS queries, binary POSTs of about 30 bytes, sent through HTTP proxies to a dozen public resolvers. The old code gave each request its own curl easy handle on a 50-thread pool. A batch of 630 requests opened 630 connections and took 10.3 seconds. The new code sends the same batch over 13 to 15 connections in 1.8 seconds.

The requests didn’t change. They now share HTTP/2 connections, which in libcurl takes a multi handle and one option that’s easy to miss.

Where the Time Actually Went

The old code looked like most libcurl code you’ll find:

CURL *c = curl_easy_init();

curl_easy_setopt(c, CURLOPT_URL, _url.c_str());
curl_easy_setopt(c, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(c, CURLOPT_POSTFIELDS, _body.data());
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, static_cast<long>(_body.size()));

CURLcode res = curl_easy_perform(c);

curl_easy_cleanup(c);

That code already negotiated HTTP/2, since curl does by default over TLS. But the connection lives inside the easy handle, and curl_easy_cleanup() closes it, so every connection carried exactly one request.

For a 30-byte query, the request is the cheap part. Before it comes a CONNECT through the proxy, a TCP handshake and a full TLS handshake. With 50 threads firing at once, one server could see dozens of handshakes at the same moment, and median latency was 783ms for an exchange that takes about 25ms once a connection exists.

Left: a client opening six separate connections to a server, each with its own TCP and TLS handshake before one request. Right: the same six requests sent as streams 1 through 11 inside one TCP and TLS connection that was set up once

HTTP/2 fixes this. One connection carries many concurrent streams, and responses come back in whatever order they finish. You pay for the handshake once.

The Multi Handle Owns the Connections

To survive past one request, a connection has to outlive the easy handle. In libcurl that means the multi handle, which keeps a connection cache. Any transfer added to it can use a connection an earlier one opened, including one still busy with other streams.

CURLM *multi = curl_multi_init();

//
// streams on one connection; this has been the default since 7.62.0,
// but I'd rather the code say it
//
curl_multi_setopt(multi, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX);

//
// at most 4 connections per host, and room in the cache for all of them
//
curl_multi_setopt(multi, CURLMOPT_MAX_HOST_CONNECTIONS, 4L);
curl_multi_setopt(multi, CURLMOPT_MAXCONNECTS, 64L);

2 and 4 connections per host performed about the same. I kept 4 as headroom in case a server limits concurrent streams per connection. MAXCONNECTS only needs room for every host’s connections, and we talk to about 12 hosts.

Easy handles are still created per request and freed afterwards. Pooling them buys nothing now that the connection lives in the multi handle.

CURL *c = curl_easy_init();

curl_easy_setopt(c, CURLOPT_URL, _req->url.c_str());
curl_easy_setopt(c, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(c, CURLOPT_POSTFIELDS, _req->body.data());
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, static_cast<long>(_req->body.size()));
curl_easy_setopt(c, CURLOPT_TIMEOUT, 5L);
curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(c, CURLOPT_WRITEDATA, _req);

//
// hand the request back to us when the transfer completes
//
curl_easy_setopt(c, CURLOPT_PRIVATE, _req);

//
// if a connection to this host is still negotiating, wait for it to confirm h2
//
curl_easy_setopt(c, CURLOPT_PIPEWAIT, 1L);

curl_multi_add_handle(multi, c);

PIPEWAIT Is the Option That Matters

This one caught me. Without CURLOPT_PIPEWAIT, a burst of requests to a host with no open connection doesn’t multiplex. curl can’t know the server speaks HTTP/2 until the first TLS handshake finishes and ALPN says “h2”, so until then every new request sees no usable connection and opens its own. By the time the first one confirms h2, all fifty have one.

Two timelines of five requests to one host. Without PIPEWAIT, all five open their own TCP, TLS and ALPN connection, giving five connections. With PIPEWAIT, request 1 opens the connection while requests 2 through 5 wait for h2 to be confirmed, then all five run as streams on one connection

With PIPEWAIT set, curl holds those requests until the first connection confirms or denies multiplexing, then puts them on it as streams. To check, I ran the example program from the end of this post: 200 queries to Cloudflare’s DoH endpoint, 50 in flight, counting new connections with CURLINFO_NUM_CONNECTS.

PIPEWAIT MAX_HOST_CONNECTIONS New connections
off unlimited (0) 50
off 4 4
on unlimited (0) 1
on 4 1

Without it, multiplexing is enabled and never used. The host cap only limits the damage.

One Thread Talks to curl

A multi handle isn’t thread-safe, and I didn’t want to turn every caller into a callback. So one I/O thread owns the multi handle and nothing else touches curl. Worker threads keep their blocking code: push a request onto a queue, wake the I/O thread, wait on a condition variable.

Four worker threads push requests into a mutex-protected queue and call curl_multi_wakeup. A single I/O thread loops through adding queued handles, curl_multi_perform, curl_multi_info_read, completing and notifying, and curl_multi_poll. Its multi handle keeps one HTTP/2 connection per host, each carrying multiple streams to hosts A, B and C

The loop is curl_multi_perform() plus curl_multi_poll(). curl_multi_socket_action() with epoll scales to far more sockets, but this program has about 15 open. Poll is plenty.

for (;;)
{
    //
    // start anything the workers have queued; m_running is checked under
    // the same lock that submit() and shutdown use
    //
    {
        std::lock_guard<std::mutex> guard(this->m_queue_lock);

        if (this->m_running == false)
        {
            break;
        }

        while (this->m_queue.empty() == false)
        {
            this->_start(this->m_queue.front());
            this->m_queue.pop_front();
        }
    }

    int still_running = 0;
    curl_multi_perform(this->m_multi, &still_running);

    //
    // harvest completed transfers
    //
    int left = 0;
    CURLMsg *msg = nullptr;

    while ((msg = curl_multi_info_read(this->m_multi, &left)) != nullptr)
    {
        if (msg->msg != CURLMSG_DONE)
        {
            continue;
        }

        request_t *req = nullptr;

        curl_easy_getinfo(msg->easy_handle, CURLINFO_PRIVATE, &req);
        curl_easy_getinfo(msg->easy_handle, CURLINFO_RESPONSE_CODE, &req->http_code);
        req->result = msg->data.result;

        curl_multi_remove_handle(this->m_multi, msg->easy_handle);
        curl_easy_cleanup(msg->easy_handle);

        this->_complete(req);
    }

    //
    // sleep until there's socket activity, a timeout, or curl_multi_wakeup()
    //
    curl_multi_poll(this->m_multi, nullptr, 0, 1000, nullptr);
}

curl_multi_wakeup() is safe to call from any thread and interrupts a sleeping curl_multi_poll() immediately, so a new request joins a connection while other streams are still running on it. The producer side:

void cpool::submit(request_t *_req)
{
    {
        std::lock_guard<std::mutex> guard(this->m_queue_lock);

        if (this->m_running == false)
        {
            this->_fail(_req);
            return;
        }

        this->m_queue.push_back(_req);

        //
        // under the same lock shutdown takes before curl_multi_cleanup()
        //
        curl_multi_wakeup(this->m_multi);
    }

    std::unique_lock<std::mutex> lk(_req->lock);
    _req->cv.wait(lk, [_req] { return _req->done == true; });
}

The comment in there is about a real bug. A producer calling curl_multi_wakeup() while shutdown is inside curl_multi_cleanup() is a use-after-free, and taking the queue lock for both closes it. At shutdown, curl_multi_get_handles() (libcurl 8.4 and later) lists the transfers still in flight, so you can fail them and no worker waits forever.

The Numbers

Same machine, same batch, same HTTP CONNECT proxies, about 12 public DoH endpoints, at most 50 requests in flight:

Run Requests New connections p50 p95 Wall time
Easy handle per request, 50 threads 630 630 783ms 1058ms 10.3s
Multi handle, multiplexed (2 runs) 630 13 to 15 22 to 27ms 250 to 295ms 1.8s
Multi handle, multiplexed 1,260 16 27ms 279ms 3.4s

These are single runs from one environment, so treat the latencies as illustrative. The connection counts will hold anywhere: doubling the batch to 1,260 requests needed 16. I didn’t measure CPU or memory, though 615 fewer TLS handshakes can’t have hurt.

The p95 dropped far less than the median. The tail is mostly slow resolvers, and multiplexing can’t make a slow server answer faster.

One result surprised me. With a handshake per request, one public endpoint refused a share of them through one of our proxies: 13.3% of requests failed in one run and 7.7% in another, with SSL connect error and Send failure: Broken pipe. Multiplexed, 0 of 600 failed. Without the proxy, the per-request test had no failures either, so it depended on the path the traffic took. I don’t know the server’s rule, but dozens of simultaneous handshakes from one address is exactly the pattern that trips one.

When the Server Only Speaks HTTP/1.1

None of this code asks for HTTP/2. curl offers h2 and http/1.1 in ALPN and the server picks. If it picks HTTP/1.1, nothing errors: PIPEWAIT releases the waiting requests, and each one needs a connection of its own.

The multi handle still reuses HTTP/1.1 connections, so this beats the old code. But an HTTP/1.1 connection carries one request at a time, which turns the connection cap into a concurrency cap. At 4, only 4 requests to that host run at once and the rest queue inside curl.

Per the curl docs, a queued transfer is already counting down its CURLOPT_TIMEOUT. I pointed the example at a local HTTPS server that offers only HTTP/1.1 in ALPN, sent 200 requests with 50 in flight, and had it take 50ms or 500ms per response:

Server response time MAX_HOST_CONNECTIONS Connections Timed out
50ms 4 4 0 of 200
50ms unlimited (0) 50 0 of 200
500ms 4 51 117 of 200
500ms unlimited (0) 50 0 of 200

At 50ms, 4 connections keep up. At 500ms they manage 8 requests a second, and the 5-second timeout runs out on requests that never got a connection. Each timeout also closes the connection it was using, which is why that row opened 51. A cap sized for HTTP/2, where each connection carries up to 100 streams by default, is far too small for a server that falls back.

The cap covers every host on the multi handle, so you can’t raise it for one. You can find out which hosts fell back, though. CURLINFO_HTTP_VERSION reports what was negotiated:

long version = 0;

curl_easy_getinfo(msg->easy_handle, CURLINFO_HTTP_VERSION, &version);

//
// the server picked HTTP/1.1 in ALPN; this host can't multiplex
//
if ((req->result == CURLE_OK) && (version != CURL_HTTP_VERSION_2_0))
{
    fprintf(stderr, "%s negotiated HTTP/1.x, no multiplexing\n", req->url.c_str());
}

For an HTTP/1.1-only host you have to keep, give it its own multi handle with a higher cap, or a timeout that covers the time spent queued.

What Goes Wrong

Proxies split the connection pool. curl keys cached connections on the proxy as well as the host, since a tunnel through proxy A can’t carry a request for proxy B. Pick a proxy at random per request and requests to the same server land on different connections, and multiplexing quietly stops. We pin each destination host to one proxy and move it only after a connection-level failure.

Streams share fate. When a connection dies, every stream on it dies too. We saw Error in the HTTP2 framing layer and Failed sending data to the peer take out 6 to 8 requests at once. Per-request retries are no longer optional. Ours retries once after moving the host to another proxy, and in testing every retry succeeded.

Don’t force HTTP/1.1 to make an error go away. We tried it while debugging. One DoH server answered 505 HTTP Version Not Supported and two others refused the TLS negotiation. Plenty of DoH servers only speak h2.

The write callback has to be binary safe. A DNS response is full of NUL bytes, and a callback that treats the buffer as a C string truncates it without complaint:

static size_t write_cb(char *_ptr, size_t _size, size_t _nmemb, void *_data)
{
    //
    // append with a length; never strcat() or std::string(_ptr)
    //
    static_cast<request_t *>(_data)->response.append(_ptr, _size * _nmemb);

    return _size * _nmemb;
}

Idle connections expire. curl won’t reuse a connection idle longer than CURLOPT_MAXAGE_CONN, 118 seconds by default, so after a quiet stretch the next burst pays one handshake per host. Worth knowing if your traffic arrives in bursts minutes apart.

You need nghttp2. CURLPIPE_MULTIPLEX does nothing if libcurl was built without HTTP/2. If the curl tool uses the same library, curl --version should list HTTP2 under Features. curl_multi_poll() and curl_multi_wakeup() need 7.68.0 or later. Everything here ran on libcurl 8.18.0 with nghttp2 1.43.0.

A Complete Example

A stripped-down version you can compile and run. It sends A queries for example.com to Cloudflare’s DoH endpoint from a pool of worker threads and counts new connections. Build it with g++ -std=c++23 -O2 h2mux.cpp -o h2mux -lcurl, run ./h2mux 200 50, then comment out the PIPEWAIT line and run it again.

#include <stdio.h>
#include <stdlib.h>
#include <string>
#include <vector>
#include <deque>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <atomic>
#include <curl/curl.h>

struct request_t
{
    std::string url;
    std::string body;
    std::string response;
    long http_code = 0;
    CURLcode result = CURLE_OK;
    bool done = false;
    std::mutex lock;
    std::condition_variable cv;
};

static std::mutex g_queue_lock;
static std::deque<request_t *> g_queue;
static bool g_running = true;
static long g_connects = 0;
static CURLM *g_multi = nullptr;
static curl_slist *g_headers = nullptr;

static size_t write_cb(char *_ptr, size_t _size, size_t _nmemb, void *_data)
{
    static_cast<request_t *>(_data)->response.append(_ptr, _size * _nmemb);
    return _size * _nmemb;
}

//
// a minimal DNS query in wire format: header, then QNAME, QTYPE A, QCLASS IN
//
static std::string dns_query(const std::string &_name)
{
    std::string q("\x00\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00", 12);
    size_t start = 0;

    while (start < _name.size())
    {
        size_t dot = _name.find('.', start);
        if (dot == std::string::npos)
        {
            dot = _name.size();
        }

        q += static_cast<char>(dot - start);
        q += _name.substr(start, dot - start);
        start = dot + 1;
    }

    q += std::string("\x00\x00\x01\x00\x01", 5);
    return q;
}

static void start(request_t *_req)
{
    CURL *c = curl_easy_init();

    curl_easy_setopt(c, CURLOPT_URL, _req->url.c_str());
    curl_easy_setopt(c, CURLOPT_HTTPHEADER, g_headers);
    curl_easy_setopt(c, CURLOPT_POSTFIELDS, _req->body.data());
    curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, static_cast<long>(_req->body.size()));
    curl_easy_setopt(c, CURLOPT_TIMEOUT, 5L);
    curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, write_cb);
    curl_easy_setopt(c, CURLOPT_WRITEDATA, _req);
    curl_easy_setopt(c, CURLOPT_PRIVATE, _req);
    curl_easy_setopt(c, CURLOPT_PIPEWAIT, 1L);

    curl_multi_add_handle(g_multi, c);
}

static void io_thread()
{
    for (;;)
    {
        {
            std::lock_guard<std::mutex> guard(g_queue_lock);

            if (g_running == false)
            {
                break;
            }
            while (g_queue.empty() == false)
            {
                start(g_queue.front());
                g_queue.pop_front();
            }
        }

        int still_running = 0;
        curl_multi_perform(g_multi, &still_running);

        int left = 0;
        CURLMsg *msg = nullptr;

        while ((msg = curl_multi_info_read(g_multi, &left)) != nullptr)
        {
            if (msg->msg != CURLMSG_DONE)
            {
                continue;
            }

            request_t *req = nullptr;
            long connects = 0;

            curl_easy_getinfo(msg->easy_handle, CURLINFO_PRIVATE, &req);
            curl_easy_getinfo(msg->easy_handle, CURLINFO_RESPONSE_CODE, &req->http_code);
            curl_easy_getinfo(msg->easy_handle, CURLINFO_NUM_CONNECTS, &connects);
            req->result = msg->data.result;
            g_connects += connects;

            curl_multi_remove_handle(g_multi, msg->easy_handle);
            curl_easy_cleanup(msg->easy_handle);

            std::lock_guard<std::mutex> guard(req->lock);
            req->done = true;
            req->cv.notify_one();
        }

        curl_multi_poll(g_multi, nullptr, 0, 1000, nullptr);
    }
}

static void submit(request_t *_req)
{
    {
        std::lock_guard<std::mutex> guard(g_queue_lock);

        g_queue.push_back(_req);
        curl_multi_wakeup(g_multi);
    }

    std::unique_lock<std::mutex> lk(_req->lock);
    _req->cv.wait(lk, [_req] { return _req->done == true; });
}

int main(int _argc, char **_argv)
{
    int total = (_argc > 1) ? atoi(_argv[1]) : 100;
    int threads = (_argc > 2) ? atoi(_argv[2]) : 20;

    curl_global_init(CURL_GLOBAL_DEFAULT);

    g_multi = curl_multi_init();
    curl_multi_setopt(g_multi, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX);
    curl_multi_setopt(g_multi, CURLMOPT_MAX_HOST_CONNECTIONS, 4L);
    curl_multi_setopt(g_multi, CURLMOPT_MAXCONNECTS, 64L);

    g_headers = curl_slist_append(g_headers, "Content-Type: application/dns-message");
    g_headers = curl_slist_append(g_headers, "Accept: application/dns-message");

    std::thread io(io_thread);

    std::vector<request_t> reqs(total);
    for (auto &r : reqs)
    {
        r.url = "https://cloudflare-dns.com/dns-query";
        r.body = dns_query("example.com");
    }

    //
    // workers block on one request at a time, exactly like the old code
    //
    std::atomic<int> next{0};
    std::vector<std::thread> workers;

    for (int t = 0; t < threads; t++)
    {
        workers.emplace_back([&]
        {
            int i;
            while ((i = next++) < total)
            {
                submit(&reqs[i]);
            }
        });
    }
    for (auto &w : workers)
    {
        w.join();
    }

    int ok = 0;
    for (auto &r : reqs)
    {
        if ((r.result == CURLE_OK) && (r.http_code == 200))
        {
            ok++;
        }
    }

    {
        std::lock_guard<std::mutex> guard(g_queue_lock);

        g_running = false;
        curl_multi_wakeup(g_multi);
    }
    io.join();

    curl_multi_cleanup(g_multi);
    curl_slist_free_all(g_headers);
    curl_global_cleanup();

    printf("%d/%d ok, connections opened: %ld\n", ok, total, g_connects);
    return 0;
}

Here it prints 200/200 ok, connections opened: 1. Without PIPEWAIT it opens 4, and with the host cap removed as well it opens 50.

PgBouncer, Prepared Statements, and Picking the Right Pool Mode

PgBouncer is the standard answer when an application opens more PostgreSQL connections than the server can usefully service. A process-per-request runtime and a serverless function that dials the database directly get there by completely different routes and land in the same place. The part that goes wrong is the pool mode, because the aggressive setting is the one everybody wants and it silently changes the semantics your code was written against.

Three Modes, One Real Decision

Session pooling assigns a server connection for the life of the client connection. It is safe and it buys you very little, since a worker process holding a connection for the length of a request is the situation you were trying to fix.

Transaction pooling assigns a server connection for the length of a transaction and returns it to the pool at commit. This is the mode that delivers the numbers people install PgBouncer for, and it is the mode with consequences.

Statement pooling returns the connection after every individual statement, which forbids multi-statement transactions entirely. It exists for specific workloads and is almost never what you want.

What Transaction Mode Takes Away

The rule is that anything living outside a transaction stops being reliable, because between two statements you may be on a different backend. That is a longer list than it first appears:

SET / RESET at session scope
LISTEN / NOTIFY
advisory locks taken outside a transaction
WITH HOLD cursors
temporary tables
session-scoped GUCs set by a connection hook

Session-level SET is the one that catches real applications. A framework bootstrap that sets search_path or timezone once on connect works perfectly in development against a direct connection, and in production the setting lands on whichever backend happened to serve that statement. Every later query gets a different backend without it. The failure is intermittent and nearly impossible to reproduce on demand.

Advisory locks are worse, because the failure is silent rather than noisy. A lock taken with pg_advisory_lock() outside a transaction belongs to a session you no longer control. The transaction-scoped variant, pg_advisory_xact_lock(), is released at commit and behaves correctly under transaction pooling. If you use advisory locks for job coordination, that one function name is the difference between working and quietly running the same job twice.

Prepared Statements Are Fixed, with Conditions

For years the answer to prepared statements under transaction pooling was that you could not use them. PgBouncer 1.21, released in October 2023, changed that. Set max_prepared_statements to a non-zero value and PgBouncer tracks named prepared statements itself, rewriting them to internal names and re-preparing them on whichever backend a client lands on:

[pgbouncer]
pool_mode = transaction
max_prepared_statements = 200

The value is the size of an LRU cache of statements kept on each server connection, so it wants to be at least as large as the number of distinct statements a typical request issues. Zero disables the feature and restores the old behavior.

The condition attached is important. This only works for protocol-level prepared statements, the ones sent through the extended query protocol. A literal PREPARE foo AS SELECT ... sent as a plain text query is invisible to PgBouncer and still breaks, because from the outside it is just another statement.

For PHP specifically, this means checking what PDO is actually doing. With ATTR_EMULATE_PREPARES left on, PDO interpolates parameters client-side and sends plain SQL, so there are no real prepared statements and nothing to break. Turn emulation off and you get genuine protocol-level prepares, which is what you want for both correctness and plan reuse, and which is exactly the case max_prepared_statements exists to handle.

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_EMULATE_PREPARES => false,
    PDO::ATTR_ERRMODE          => PDO::ERRMODE_EXCEPTION,
]);

How I Choose

Start with transaction mode, then go looking for the session state your application depends on rather than waiting for it to surface. Grep for LISTEN, for pg_advisory_lock, for temporary tables, and for anything setting a GUC on connect. Move search_path out of a connection hook and into the connection string, where PgBouncer passes it through as part of the startup parameters and it survives correctly.

Keep a second pool in session mode on a different port for the small number of things that genuinely need a stable session, which is usually a migration runner and whatever handles LISTEN. Two pools with clear rules beats one pool with exceptions nobody remembers.

And size the pool against what PostgreSQL can actually do, since the point of pooling is to keep the database out of the region where it spends more time context switching than working. A pool larger than the database can service just moves the queue.

MTA-STS and TLS-RPT: Enforcing TLS on Inbound Mail

SMTP encryption is opportunistic by default, which means it is optional, which means it is strippable. A sending server connects, looks for STARTTLS in the EHLO response, and upgrades if it sees it. Remove that one line in transit and the sender falls back to plaintext without complaint, because it has no way to know TLS was ever supposed to happen. MTA-STS is how you tell it.

Why STARTTLS Alone Is Not Enough

The gap is that opportunistic TLS has no expectation to violate. A sender that fails to negotiate TLS has no basis to refuse delivery, since plenty of legitimate mail servers still do not offer it. Certificate validation is usually skipped for the same reason: a mail server presenting a self-signed or expired certificate is common enough that treating it as fatal would break real delivery.

So the default posture is an encrypted channel to an unverified party, downgradeable by anyone in the path. MTA-STS, specified in RFC 8461, lets a receiving domain publish a policy saying TLS is required, the certificate must validate, and here are the hostnames allowed to receive mail. Senders that implement it will refuse to deliver rather than fall back.

The Three Pieces

First, a DNS TXT record at the _mta-sts label telling senders a policy exists:

_mta-sts.example.com.  IN  TXT  "v=STSv1; id=20260916120000Z;"

The id is the whole mechanism for cache invalidation. Senders compare it against the one they saw last time, and they only re-fetch the policy when it changes. Bump it on every policy edit or your change will not be noticed until caches expire on their own.

Second, the policy itself, served over HTTPS from a specific host and path:

https://mta-sts.example.com/.well-known/mta-sts.txt
version: STSv1
mode: enforce
mx: mail.example.com
mx: mail2.example.com
max_age: 604800

mode takes enforce, testing, or none. Every MX that can receive mail for the domain needs a line, and a wildcard like *.example.com matches only the leftmost label, so it covers mail.example.com and not foo.bar.example.com. max_age is in seconds with a ceiling of 31557600, about a year.

Third, and this is the part people trip over: the mta-sts host serving that file needs a valid, publicly trusted certificate of its own. The policy is only as trustworthy as the HTTPS connection that delivered it, so a sender that cannot validate that certificate discards the policy entirely. You have now made your mail delivery depend on a certificate on a host that has nothing else to do with mail, which is exactly the kind of endpoint that quietly expires.

Start in Testing Mode

Publishing mode: enforce as the first step is how you find out about your forgotten backup MX by having mail to it rejected. mode: testing makes senders evaluate the policy, report failures, and deliver anyway.

Leave it there long enough to see a full cycle of your real traffic. A week is reasonable, longer if you have partners who send in bursts. The reports are the point of the exercise, which brings up the other half.

TLS-RPT Tells You What Broke

RFC 8460 defines a companion record that asks senders to report what happened. It is cheap to add and it is the only feedback channel you get:

_smtp._tls.example.com.  IN  TXT  "v=TLSRPTv1; rua=mailto:tlsrpt@example.com"

Reports arrive as JSON, daily, from each sending organization that implements it. The useful part is the failure detail:

{
  "organization-name": "Example Sender Inc",
  "date-range": { "start-datetime": "2026-09-16T00:00:00Z",
                  "end-datetime":   "2026-09-16T23:59:59Z" },
  "report-id": "2026-09-16T00:00:00Z_example.com",
  "policies": [{
    "policy": { "policy-type": "sts", "policy-domain": "example.com" },
    "summary": { "total-successful-session-count": 8214,
                 "total-failure-session-count": 17 },
    "failure-details": [{
      "result-type": "certificate-host-mismatch",
      "sending-mta-ip": "203.0.113.9",
      "receiving-mx-hostname": "mail2.example.com",
      "failed-session-count": 17
    }]
  }]
}

The result-type values are specific enough to act on directly. starttls-not-supported, certificate-expired, certificate-host-mismatch, certificate-not-trusted, validation-failure, and on the policy side sts-policy-fetch-error, sts-policy-invalid, and sts-webpki-invalid. That last group points at your policy host rather than your mail servers, which is a distinction worth internalizing before you start debugging the wrong machine.

The Ways This Bites

Caching cuts both ways. Once a sender has your policy with a week-long max_age, a mistake persists for that long even after you fix the file, unless the sender happens to re-check the id. Adding an MX means updating the policy and bumping the id before the new host starts receiving, and doing it in that order.

The certificate on the mta-sts web host is the failure nobody plans for. It is usually a static file on a server that gets less attention than the mail infrastructure, and when it expires, compliant senders stop trusting your policy. Whether that means mail is deferred or the policy is simply ignored depends on the sender.

Your MX certificates now genuinely have to be correct as well. Names have to match what the policy lists, chains have to be complete, and expiry stops being cosmetic. That is the point of turning it on, and it is worth knowing before you flip to enforce.

To check what you have published, the MTA-STS checker on mrdns.com fetches the record and the policy together and tells you whether they agree. For the mail server configuration underneath it, goodtls.com has per-application guides covering Postfix, Exim, Dovecot, and the rest.

Where AI Compute Goes When It Comes Home

In my last post I argued that once model progress levels off, inference moves toward the people using it. The closest person using it is sitting in your living room. So I went and counted the hardware built to put a model there: 21 desktop AI boxes announced or shipped in the last twelve months, from 16 companies. Most launched somewhere between $2,000 and $4,000.

Twenty-One Boxes in a Year

Nvidia announced Project DIGITS at CES in January 2025: 128GB of unified memory for “$3,000,” due in May. Apple shipped first. Two months later the Mac Studio could be configured with up to 512GB, which Apple called “the most unified memory ever in a personal computer,” and it pitched the machine as able to run models “with over 600 billion parameters entirely in memory.” DIGITS finally went on sale in October as the DGX Spark, at $3,999.

Then everyone piled in. Seven PC makers sell their own version of the Spark’s GB10 board. AMD’s Ryzen AI Max+ 395, the chip everyone calls Strix Halo, ended up in mini PCs from Framework and a long list of smaller brands, and this summer AMD started selling its own box through Micro Center. On August 25 Apple announced the M5 Mac Studio, shipping September 22.

Platform Sold by Max memory Memory bandwidth Price
Nvidia GB10 Nvidia, Acer, ASUS, Dell, Gigabyte, HP, Lenovo, MSI 128GB 273GB/s DGX Spark: $3,999 at launch, $4,699 since February
AMD Ryzen AI Max+ 395 AMD, Framework, many mini PC brands 128GB 256GB/s AMD Ryzen AI Halo: $3,999.99
Apple M5 Max Apple (Mac Studio) 128GB 614GB/s From $2,499
Apple M5 Ultra Apple (Mac Studio) 512GB 1.2TB/s From $5,499; 512GB config due late October

Then Memory Got Expensive

What makes these boxes useful is a big pool of fast memory, and memory is the part that blew up. TrendForce says conventional DRAM contract prices rose roughly 93% to 98% in the first quarter of 2026 alone, with another 58% to 63% forecast for the second. It puts the blame on AI servers soaking up general-purpose memory. The same build-out I wrote about last time is buying the same chips.

Device Launch price Price now
Raspberry Pi 5, 16GB $120 (January 2025) $305
Framework Desktop, 128GB $1,999 (February 2025) $3,449, out of stock
Mac Studio, base $1,999 (March 2025) $2,499 (June 2026)
Mac Studio, M3 Ultra $3,999 (March 2025) $5,299 (June 2026)
Nvidia DGX Spark $3,999 (October 2025) $4,699 (February 2026)

Tim Cook called it a “hundred-year flood” when Apple raised Mac prices in June. Raspberry Pi says the LPDDR4 on its boards went up seven-fold in a year. That one matters more than the Mac, and I’ll come back to it.

The Box Next to the Router

Here’s the world I think these boxes point to. Every house has a small, quiet machine on the shelf beside the Wi-Fi router. It holds the family’s mail, photos, documents, and calendar, and it runs a model good enough to answer questions about all of it. Nothing leaves the house. There’s no per-token bill, and no status page to refresh when a provider has a bad afternoon.

The worry is already there. In Pew’s February 2026 survey, roughly seven in ten Americans said AI will make their personal information less secure. Apple is selling the new Mac Studio on exactly that: its launch copy says you can “run massive models entirely on device with complete privacy.” The biggest home assistant went the other way. In March 2025 Amazon removed the “Do Not Send Voice Recordings” option from several Echo devices, because its generative Alexa features “rely on the processing power of Amazon’s secure cloud.”

Power is a practical problem. A Netgear Orbi router idles at 7.4W. ServeTheHome measured a DGX Spark idling at 40 to 45W and drawing 60 to 90W during LLM inference. That’s fine on a developer’s desk. It’s a harder sell for something that runs all day in a hallway closet.

Price is the bigger one. Nobody puts a $4,699 box next to their router. The home version has to cost about what a good router costs, so it can’t depend on a big GPU or a giant pool of premium unified memory. It has to run on ordinary hardware. That sounds out of reach today. I don’t think it is.

Memory Bandwidth Sets the Speed Limit

Generating text on a local model is mostly a memory problem. For every token, the machine reads the model’s active weights out of memory, so the ceiling on speed is roughly how fast it can move bytes, divided by how many bytes each token needs.

tokens/sec ceiling  ~  memory bandwidth / bytes read per token

dense 70B, 4-bit            ~40 GB per token
  Strix Halo @ 256 GB/s     ~6 tok/s ceiling       measured: 5.0

MoE, 3B active, 4-bit       ~2 GB per token
  Strix Halo @ 256 GB/s     ~130 tok/s ceiling     measured: 72.0

Apple’s ML team showed this cleanly when it tested the M5. Memory bandwidth went from 120GB/s on the M4 to 153GB/s, a 28% bump, and token generation got 19% to 27% faster. Time to first token is compute-bound, and it improved 3.3x to 4x.

Here’s where the hardware lands.

Hardware Memory bandwidth
Desktop, dual-channel DDR5-5600 ~90GB/s on paper
AMD Ryzen AI Max+ 395 256GB/s
Nvidia GB10 273GB/s
Apple M4 Max 410 to 546GB/s
Apple M5 Max 614GB/s
Apple M3 Ultra 819GB/s
Apple M5 Ultra 1.2TB/s

An ordinary desktop gets you about a third of a Spark, and the top Mac Studio is more than four times past the Spark. On bandwidth alone, cheap hardware loses badly.

Mixture of Experts Changed the Math

A mixture-of-experts (MoE) model splits its weights into many small expert networks and only runs a few of them per token. Qwen3.6-35B-A3B has 35B parameters in total and activates 3B. Total parameters decide how much memory you need. Active parameters decide how fast it runs. That split is the whole case for cheap hardware, because ordinary DDR5 is slow but you can put a lot of it in an ordinary machine.

Hardware Dense model Speed MoE model Speed
Nvidia DGX Spark Llama 3.1 70B, FP8 2.7 tok/s gpt-oss-120b, MXFP4 58.7 tok/s
AMD Strix Halo Llama 3 70B fine-tune, Q4_K_M 5.0 tok/s Qwen3-30B-A3B, Q4 72.0 tok/s
Laptop, dual-channel DDR5-5600, CPU only Qwen2.5-Coder 32B 3.5 tok/s Qwen3-Coder-Next 80B-A3B 7.7 tok/s
Raspberry Pi 5, 16GB Qwen3-30B-A3B, 2.7 bits per weight 8.0 tok/s

These numbers come from different people using different tools, so don’t read them too precisely. The gaps are too big to be noise, though: the same Spark runs a 117B MoE model more than 20 times faster than a dense 70B.

The laptop row is the one I care about. It’s a CPU with ordinary RAM, and an 80B MoE model runs twice as fast on it as a dense 32B. The person who posted those numbers says the MoE result is still 3 to 4 times slower than the bandwidth math predicts, so the software has room left. Then there’s the Pi: a $305 board running a 30B model at 8 tokens a second, at a quantization that keeps about 94% of full-precision quality.

The software is closing that gap quickly. llama.cpp added --cpu-moe in August 2025, which keeps the expert weights in system RAM and puts the rest on whatever GPU you have. Running Qwen3.6-35B-A3B that way is one line:

llama-server -hf unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_M -ngl 99 --cpu-moe

One user runs a community fine-tune of that model this way on a Ryzen 7 5800X with a 6GB GTX 1660 Super, and gets almost 16 tokens a second. That’s a gaming PC from 2020.

Multi-token prediction landed in llama.cpp in May 2026. On an RTX 3090, Qwen3.6-27B went from 23 to 42 tokens a second from a software update. Apple’s M4-to-M5 jump bought 19% to 27%.

The models keep shrinking for the same capability, too. The Densing Law paper estimates the capability density of LLMs doubles roughly every three months. Epoch AI found a single RTX 5090 runs models that matched the frontier 6 to 12 months earlier, and it notes its method is biased against sparse models, so MoE likely shortens that lag.

What Still Doesn’t Work on Cheap Hardware

Reading the prompt. Generation is bound by bandwidth, but prompt processing is bound by compute, and CPUs are slow at it. On a 48-core EPYC with 12 channels of DDR5, CPU only, gpt-oss-120b processes prompts at about 109 tokens a second. A 32K-token prompt means at least five minutes before the first word comes back. The Spark’s GPU does the same model at about 2,400. For a chat, that’s fine. For “summarize my whole inbox,” a CPU box has to do the reading ahead of time, overnight, while nobody’s waiting. For something that sits next to the router all day, that’s a reasonable design.

New architectures. On that same EPYC, Qwen3-Next-80B-A3B, which uses a newer hybrid attention design, ran at 11.8 tokens a second. Qwen3-30B-A3B, with about the same active size, ran at 63.1. The software catches up, but the cheapest hardware tends to get the fast path last.

Quality. The Pi result keeps 94% of full-precision quality, which also means it gives up 6%. For a house assistant answering questions about the calendar, I’d take that trade. For hard reasoning, I wouldn’t.

Memory prices. The cheap-hardware argument depends on plenty of ordinary RAM, and the LPDDR4 on that Pi is up seven-fold in a year. The Pi 5 16GB launched at $120 in January 2025. It’s $305 now.

My bet is that the home AI box ends up being a cheap machine with a lot of ordinary memory, running a MoE model and doing its heavy reading while the house sleeps. The $4,000 developer boxes are proving the demand and paying for the software work that gets there. The silicon exists. The models are getting there on a three-month doubling. What’s holding it back in 2026 is the price of DRAM, and that’s a supply problem.

When supply catches up, I expect the box next to the router to cost about what the router did.

The 200-Day Certificate Is Already Here

Most of the coverage of shrinking certificate lifetimes is written as a warning about 2029. That framing is five months out of date. The first step already happened: since March 15, 2026, publicly trusted TLS certificates have been capped at 200 days. If your renewal process still assumes an annual cadence, it is already wrong.

The Schedule

CA/Browser Forum ballot SC-081v3 passed in April 2025 and set out a staged reduction rather than a single cliff:

before 2026-03-15    398 days
from   2026-03-15    200 days     <- in force now
from   2027-03-15    100 days
from   2029-03-15     47 days

Domain validation data reuse shrinks alongside it, from 398 days down to 10 days for SAN validation by the 2029 milestone. That second number gets less attention and matters more for anyone with a manual approval step, because it means the validation itself stops being something you do once a year and file away.

The values are deliberately awkward. 200, 100, and 47 are not round, and the reasoning is that a number you cannot map onto "twice a year" or "once a quarter" discourages anyone from building a manual calendar reminder around it. Whether that works as intended, it does communicate the intent clearly enough.

What Actually Breaks

ACME-managed web servers are fine. If nginx or Apache is fronted by certbot or an equivalent, the cadence change is invisible. The breakage is concentrated in the places nobody automated, and in my experience it is the same short list every time.

Appliances with no ACME client. Load balancer management interfaces, older firewalls, IPMI and out-of-band controllers, storage arrays, VPN concentrators. Many of these accept a certificate only through a web form. Some accept only a specific bundle format. At 398 days that was an annual annoyance somebody remembered. At 100 days it is quarterly, and at 47 days it is not survivable by hand.

Non-HTTPS services. Mail is the big one, because a mail server presents a certificate on SMTP, submission, IMAP, and POP3, and renewing the web certificate does nothing for any of them unless something copies the file and reloads the daemon. The reload is the step people forget. A renewed certificate sitting on disk while the running process holds the old one in memory is an outage waiting for the expiry date.

Pinned certificates and manually distributed trust. Anything where a partner has your specific certificate configured on their end now needs that coordination three to seven times a year instead of once.

What to Do About It

Inventory first. You cannot automate endpoints you do not know about, and every environment I have looked at has certificates nobody remembered issuing. Scan your own address space by port rather than trusting a spreadsheet.

Automate what can be automated, then deal honestly with what cannot. For the appliance that genuinely has no API, the answer is a documented runbook with an owner and a calendar entry, not an intention. Write down who renews it, how, and what breaks if they do not.

Then monitor the endpoint rather than the issuance. This is the part people skip. Certificate management tools tell you what they issued, and they cannot tell you what a client actually receives, which is where incomplete chains, stale files, and un-reloaded daemons live. A quick manual check of what a server is presenting:

openssl s_client -connect mail.example.com:465 -servername mail.example.com </dev/null 2>/dev/null \
    | openssl x509 -noout -subject -dates

For a one-off look at a public endpoint, including the chain, the SSL check on mrdns.com does the same thing without the command line. For getting the underlying configuration right on whatever you are running, goodtls.com has per-application guides, including the mail servers and databases that tend to be the ones running on expired certificates.

The one thing I would push back on is treating 2029 as the deadline. March 2027 is the milestone that hurts, because 100 days is where quarterly manual renewal stops being merely tedious and starts producing outages. That is seven months away.