Smaller footprint: three times the tabs, a third of the image, a budget per session #289

Closed
opened 2026-09-06 07:43:13 +00:00 by jcoffey-dev · 0 comments
Owner

Three commits, one concern each, all measured against a real Stalwart 0.16.20
rather than the mock. Review per commit.

1. Cut what a signed-in tab costs by two thirds

256 MiB container Tabs held Per tab 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 and the proxy followed them, so every
held push stream went out over TLS even when STALWART_URL named a private
plain-HTTP hop. That leg is ~80 KiB of OpenSSL state per tab — native memory,
which is why heap and buffer settings never moved the number (the buffer
change was spiked and measured: no effect at 16 KiB or 8 KiB, and −11%
throughput at 8 KiB, so it is not here). absoluteUpstream() now keeps the
advertised path and query but the configured scheme, host and port;
STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behaviour.

The fetch() relay was the next cost: ~44 KiB of heap per tab in undici and
web-stream objects held for the life of the stream. relayPushRaw() pipes the
upstream socket straight into the Node response. RAW_PUSH_RELAY=0 keeps the
old path for comparison.

Security note, since it is the point: the saving only exists when the
operator names a plain-HTTP route, which is only correct on a private segment.
With an https STALWART_URL nothing changes. The install doc (companion PR)
says this in as many words.

2. Ship the runtime image without the build tree

639 MB → 239 MB unpacked, 119 MB → 59 MB compressed. The runtime stage
copied the build stage's whole node_modules (132 MB; the server loads 4 MB),
then chown -R /app duplicated it into a second layer. Now --omit=dev for
the server workspace, /app left root-owned, and npm/npx/yarn/corepack removed
from the runtime stage. Verified: starts --read-only, serves, signs in, holds
push, hono loads from the 3.1 MB that remains.

3. Give each session a budget on the data path

Only sign-in was rate limited. JMAP, blobs, image and calendar proxies now get
API_RATE_LIMIT per session per minute (default 1,200, 0 disables). One core
saturates around 2,000 ops/s, so one looping account could slow everyone.
Verified in situ: a hot session is cut off after exactly 1,200; with the
budget off, throughput at 50 users is unchanged (2,383/s vs 2,484/s).

Not changed

JMAP throughput ceiling (one core, ~2,000–2,800 ops/s) — that needs workers
with sticky sessions, which would trade the idle-memory advantage; a separate
decision. The --max-old-space-size doc advice is withdrawn in the companion
docs PR.

152 tests. No user-visible strings, so no work in any of the nine catalogues.

Merged 2026-09-06 as coffey-labs/ihasmail@3fd0d0cfa6

Rebuilt from: GH Archive, git history, session transcript.

Three commits, one concern each, all measured against a real Stalwart 0.16.20 rather than the mock. Review per commit. ### 1. Cut what a signed-in tab costs by two thirds | 256 MiB container | Tabs held | Per tab | 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 and the proxy followed them, so every held push stream went out over TLS even when `STALWART_URL` named a private plain-HTTP hop. That leg is ~80 KiB of OpenSSL state per tab — native memory, which is why heap and buffer settings never moved the number (the buffer change was spiked and measured: no effect at 16 KiB or 8 KiB, and −11% throughput at 8 KiB, so it is not here). `absoluteUpstream()` now keeps the advertised path and query but the configured scheme, host and port; `STALWART_FOLLOW_ADVERTISED_URLS=1` restores the old behaviour. The `fetch()` relay was the next cost: ~44 KiB of heap per tab in undici and web-stream objects held for the life of the stream. `relayPushRaw()` pipes the upstream socket straight into the Node response. `RAW_PUSH_RELAY=0` keeps the old path for comparison. **Security note, since it is the point:** the saving only exists when the operator names a plain-HTTP route, which is only correct on a private segment. With an https `STALWART_URL` nothing changes. The install doc (companion PR) says this in as many words. ### 2. Ship the runtime image without the build tree 639 MB → **239 MB** unpacked, 119 MB → **59 MB** compressed. The runtime stage copied the build stage's whole `node_modules` (132 MB; the server loads 4 MB), then `chown -R /app` duplicated it into a second layer. Now `--omit=dev` for the server workspace, `/app` left root-owned, and npm/npx/yarn/corepack removed from the runtime stage. Verified: starts `--read-only`, serves, signs in, holds push, `hono` loads from the 3.1 MB that remains. ### 3. Give each session a budget on the data path Only sign-in was rate limited. JMAP, blobs, image and calendar proxies now get `API_RATE_LIMIT` per session per minute (default 1,200, `0` disables). One core saturates around 2,000 ops/s, so one looping account could slow everyone. Verified in situ: a hot session is cut off after exactly 1,200; with the budget off, throughput at 50 users is unchanged (2,383/s vs 2,484/s). ### Not changed JMAP throughput ceiling (one core, ~2,000–2,800 ops/s) — that needs workers with sticky sessions, which would trade the idle-memory advantage; a separate decision. The `--max-old-space-size` doc advice is withdrawn in the companion docs PR. 152 tests. No user-visible strings, so no work in any of the nine catalogues. **Merged** 2026-09-06 as coffey-labs/ihasmail@3fd0d0cfa691 <sub>Rebuilt from: GH Archive, git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.