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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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
STALWART_URLStalwart advertises absolute https URLs and the proxy followed them, so every
held push stream went out over TLS even when
STALWART_URLnamed a privateplain-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 theadvertised path and query but the configured scheme, host and port;
STALWART_FOLLOW_ADVERTISED_URLS=1restores the old behaviour.The
fetch()relay was the next cost: ~44 KiB of heap per tab in undici andweb-stream objects held for the life of the stream.
relayPushRaw()pipes theupstream socket straight into the Node response.
RAW_PUSH_RELAY=0keeps theold 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_URLnothing 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 /appduplicated it into a second layer. Now--omit=devforthe server workspace,
/appleft root-owned, and npm/npx/yarn/corepack removedfrom the runtime stage. Verified: starts
--read-only, serves, signs in, holdspush,
honoloads 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_LIMITper session per minute (default 1,200,0disables). One coresaturates 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-sizedoc advice is withdrawn in the companiondocs 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.