Commit Graph
4 Commits
Author SHA1 Message Date
jcoffey-dev f569f2cc7a Skip the compressor for clients that offer no encoding
Listing latency at one user went from 1.95 ms on the previous release to
3.25 ms on main, and a bisect put the whole of it on the compression commit.
Not on compressing: the harness never sent Accept-Encoding, so nothing was
ever gzipped. Hono's middleware still inspects every compressible response it
declines and sets Vary on it, and setting a header on a streamed passthrough
rebuilds the Response off its fast path -- about 1.2 ms per JMAP call, on a
request that had asked for nothing.

The middleware now runs only when the request names gzip or deflate. Measured
at one user against the same Stalwart:

  compressor touches but declines, no Accept-Encoding   3.25 ms
  skipped entirely, no Accept-Encoding                  2.02 ms
  compressor applied, Accept-Encoding: gzip             2.27 ms
  previous release, either                              1.95 ms

Applying gzip to a JMAP response costs about a quarter of a millisecond and
saves three to five times the bytes on every listing and body, so JMAP
responses stay compressed by default; COMPRESS_JMAP=0 turns that off for a
deployment that would rather not.

The raw push relay is also made safe to tear down from outside -- the
browser stream keeps its headers and is not ended when the upstream request
goes -- which the next change relies on.
2026-09-06 13:22:03 -07:00
jcoffey-dev ed93fefb9b Give each session a budget on the data path
Only sign-in and the account endpoints were rate limited. JMAP, blob
downloads and the image and calendar proxies had no budget at all, and the
proxy is one Node process that saturates a core at roughly 2,000 operations a
second -- measured at 110% CPU under 150 concurrent users. One signed-in
account looping requests could slow every other user on the instance.

Each session now gets API_RATE_LIMIT requests a minute on those routes, 1,200
by default: twenty a second sustained, well above what a busy tab does and an
order of magnitude below where one tab starts to hurt the rest. Over budget
returns 429 with Retry-After. Sign-in keeps its own, separate limiter.

Checked in situ: one session driven flat out was cut off after exactly 1,200
requests, and with API_RATE_LIMIT=0 throughput at 50 users is unchanged.
2026-09-06 00:42:49 -07:00
jcoffey-dev 01f721d8d1 Cut what a signed-in tab costs by two thirds
Two changes on the push path, both measured against a real Stalwart 0.16.20
with the container capped at 256 MiB and tabs added in steps of 200 until the
kernel killed it:

                                  tabs held   per tab   of which native
  before                              1,665   133 KiB          81 KiB
  pin upstream calls to STALWART_URL  3,400    58 KiB           8 KiB
  + raw push relay                    4,979    37 KiB          10 KiB

Stalwart advertises absolute https URLs in every session, and the proxy
followed them -- so even with STALWART_URL naming a private plain-HTTP hop on
the same Docker network, every held push stream went out through TLS. That leg
is about 80 KiB of OpenSSL state per tab: native memory Node cannot see, which
is why neither the heap ceiling nor the stream buffer size ever moved the
number. absoluteUpstream() now keeps the path and query from the advertised
URL and the scheme, host and port from the configured one. A setup that must
reach Stalwart at an origin other than the one it was given sets
STALWART_FOLLOW_ADVERTISED_URLS=1.

With the transport out of the way, the fetch()-based relay was the next cost:
an undici Response, a web ReadableStream, a reader and Hono's stream bridge
held alive per tab, about 44 KiB of heap for a session that otherwise costs
4 KiB. relayPushRaw() pipes the upstream socket into the Node response and
tells the adapter the response is already sent. RAW_PUSH_RELAY=0 restores the
fetch path for comparison.

JMAP throughput is unchanged (2,383/s against 2,484/s at 50 users, inside
run-to-run noise); the relay does not touch that path. Verified that a push
stream through the raw relay delivers a StateChange while mail is written.

The install page's advice to set --max-old-space-size was measured in the same
runs and made no difference at all -- 3,400 tabs with it and without -- and
is withdrawn in the docs alongside this change.
2026-09-06 00:42:19 -07:00
Coffey Labs 5356e603fe Compress our own responses (#288)
* Compress our own responses

The bundle went out uncompressed unless a proxy in front did the work: 933 KB
on the wire where 311 KB does, on every first load. Both example proxy configs
compress, but that only helps deployments that copied them, and the default
should not depend on reading the examples.

Hono's middleware, with the proxy routes held back. `/api/blob`, `/api/image`,
`/api/ics` and `/api/upload` forward somebody else's bytes under a
content-length copied from upstream, and issue #76 was a silent truncation
caused by exactly that header disagreeing with its body. Re-encoding them
would be safe in principle -- the length is dropped and the response goes out
chunked -- but they carry attachments and images that are already compressed,
so there is nothing to win and a scar to respect.

`/api/events` is listed with them even though Hono already skips
text/event-stream by content type, so that changing the push route's type
cannot quietly start buffering the stream.

`/api/health` is excluded for the opposite reason: at 47 bytes gzip made it 73.
Hono's size threshold cannot catch that on its own, because it only applies
when a response carries a content-length and `c.json()` does not set one. The
other JSON routes stay compressed -- a JMAP response has just as unknown a
length and can run to hundreds of kilobytes.

Verified against the built image: assets come back gzipped with Vary set,
662 KB to 209 KB; /api/events still returns text/event-stream with no
content-encoding and delivered a StateChange while mail was being written;
health is 47 bytes either way. No user-visible strings, so no catalogue work.

* Word the comment for either side compressing

The app compresses its own responses as of the follow-on change, so a note
saying the bundle ships uncompressed would be wrong as soon as that lands.
nginx passes through what the upstream already encoded rather than re-encoding
it -- verified single-encoded with both layers active -- so the directives are
correct either way and the comment now says so without asserting which side
does the work.

* Test compression against a fixture, not the web build

The compression tests asked for `/` and asserted a gzipped 200. That passes
locally, where `web/dist` is lying around from an earlier build, and fails in
CI, which runs `npm test` before `npm run build`: with no bundle the shell
route serves the "web build not found" fallback, which is short, plain text and
correctly uncompressed. The failure read as compression being broken when the
tests were simply depending on a build step that had not run.

They now build their own static root in a temp directory and point STATIC_DIR
at it, in a separate file so the environment is set before the app module is
imported. Checked by moving web/dist aside and running the suite the way CI
does.
2026-09-05 23:41:29 -07:00