How much memory does Sumimi use?
A promise, the numbers behind it, and the four limits that keep it true. Sumimi is built for a 512 MB VPS, so the number that matters is not the database's — it is the whole process's peak resident set. The code pages, the collector, the queue, the pages the allocator keeps and DuckDB's own share all count, and the box runs out of all of them at once.
The promise
| tier | the machine | DuckDB's ceiling | the whole process |
|---|---|---|---|
| minimum | 1 vCPU / 512 MB | 128 MB | 256 MB |
| recommended | 1 vCPU / 1 GB | 256 MB | 512 MB |
DuckDB gets a quarter of the machine, never more than 256 MB even on a big box: its own default is 80% of RAM, which on a 512 MB VPS is 410 MB — enough to leave nothing for the operating system. The limit caps the buffer, not the data: long queries stream and spill to disk instead of growing.
The promise is checked, not hoped for: the project's CI measures the exact high-water mark on Linux every day and fails the build when the process outgrows its box.
Where the memory goes
Six accounts, and only two of them have a limit today. This table is the honest state of it:
| account | what it is | limited by | measured |
|---|---|---|---|
| the binary | the executable's text and data, the thread stacks | nothing — it is what it is | 42 MB idle |
| DuckDB's buffer | the engine's buffer manager | memory_limit | 0.5–7 MB |
| DuckDB's ingestion | the appender's rows, the write-ahead log, the checkpoint | memory_limit | the peak itself: ~150 MB at 20 000/s |
| the collector | reading, parsing and queueing a request | the four limits below | measured: almost nothing — the SQLite backend does the same 20 000/s in 32.8 MB |
| the queue | the hits waiting to be written | SUMIMI_MAX_QUEUE (rows) | ~5 MB of structs per 10 000 rows, plus the strings |
| the allocator | the pages glibc took and did not give back | MALLOC_MMAP_THRESHOLD_ | −12% to −21% of the peak when it is set |
A number that is easy to misread
/api/heartbeat reports duckdbMemoryBytes — the sum
of every tag in DuckDB's own ledger. It is still not the
engine's real footprint: the ledger counts what the engine is holding, not
what its ingestion is using. At the peak of a 20 000/s load the ledger says
22.6 MB while the process sits at 212 MB — and the difference is DuckDB's
own, bounded by memory_limit.
What we measured
| workload | the peak RSS | the engine holds |
|---|---|---|
| idle | 42 MB | 0.5 MB |
| 200 000 page views through the write path | 168 MB (128 MB engine) / 172 MB (256 MB engine) | 7 MB |
| a burst: 1 GB injected in 60 s | 636 MB | 2 MB |
| the same burst, another day | 1560 MB | 4 MB |
Two things are true here. The engine's ledger holds single-digit
megabytes in every run — and the burst is the engine: at the peak
the queue is empty and the collector's own allocations are almost nothing.
The A/B settles it — the same 20 000/s on the SQLite backend peaks
at 32.8 MB, code pages included. What is left is DuckDB's
ingestion, and it is bounded by memory_limit: 244 MB of limit
gives a 215 MB peak, 128 MB gives 178.8 MB, 64 MB gives 108 MB. The two
burst rows also show why one measurement is not enough: the same shape gave
636 MB and 1560 MB.
The four limits
The collection path is the only place where the traffic decides how much memory is used, so it is the only place that needs limits — and they sit before the body is read, because the body is what costs the memory. A request that is not admitted never allocates anything.
| environment variable | what it bounds | default |
|---|---|---|
SUMIMI_MAX_BODY_BYTES | one request's body | 2 MB |
SUMIMI_MAX_INFLIGHT_REQUESTS | requests being read or parsed at once | unlimited |
SUMIMI_MAX_INFLIGHT_BYTES | the bytes in flight at once | unlimited |
SUMIMI_MAX_ADMIT_RPS | requests admitted per second | the recommended rate for this box — 20 000/s on a 512 MB VPS, 40 000/s on a 1 GB one |
Bytes, not requests. A request is not a memory unit — a page view is a few hundred bytes and a bot can send a two-megabyte one — so the in-flight budget is counted in bytes. The rate limit is what bounds the churn: the allocator's high-water mark grows with how fast the traffic arrives, not with how much of it there is.
The rate is the one that is on by default, because a box that
never says no is a box that runs out of memory. When you do not set it, the
server works it out from the memory it has: 20 000 requests a second
for every 128 MB of the database's budget — the point where the box
is full but not over. Set it yourself when you know better; set it to
0 to turn the limit off.
To set them, put them in the environment file next to the systemd unit:
# /etc/sumimi/sumimi.env
SUMIMI_MAX_BODY_BYTES=65536
SUMIMI_MAX_INFLIGHT_BYTES=4194304
SUMIMI_MAX_ADMIT_RPS=20000
MALLOC_MMAP_THRESHOLD_=65536
The allocator gives the pages back
One line of the environment file is not a Sumimi setting, and it is worth more than all the others together.
glibc keeps the pages of a freed large block instead of returning them to the operating system — it does that because it expects to be asked for the same size again, which is usually true and is not true here. The server's peak is therefore a high-water mark that never comes back down, even after the burst that caused it is over.
MALLOC_MMAP_THRESHOLD_=65536 tells glibc to map anything over
64 KB instead of taking it from the heap: the pages go back to the OS as
soon as the block is freed. Measured on the same server, at the same rate,
on the same network, changing nothing else:
| the allocator | the peak | the change |
|---|---|---|
| the default | 274 MB | — |
MALLOC_MMAP_THRESHOLD_=65536 | 210 MB | −23% |
MALLOC_TRIM_THRESHOLD_=65536 | 222 MB | −19% |
MALLOC_ARENA_MAX=2 | 227 MB | −17% |
The cost is a syscall per large block. Measured on the 2 vCPU box, that cost is about 5% of the throughput — the pages come back, but the CPU pays for them. It is a knob, not a free win: the Docker image sets it already, a systemd deployment should set it in the environment file above, and a box that is short of memory should set it even if it could spare the 5%.
The clearest proof that the pages really go back comes from a container, because the kernel keeps two numbers for it: the high-water mark and what it holds now. Measured in a container on an Apple M5, at 5 000 requests a second:
| the allocator | the peak | what the container holds after the load |
|---|---|---|
| the image as it ships | 106 MB | 23 MiB |
| glibc as it comes | 141 MB | 56 MiB |
| never map anything | 304 MB | 178 MiB |
Five times between the highest the container ever was and what it holds once the load stops — that difference is the pages coming back. And the same machine, in the same container, without that one environment variable, peaks at 141 MB instead of 106 MB.
Linux only
These are glibc's own settings. On macOS and on the BSDs the allocator is different and the variable does nothing — the server is fine there, it just does not need the help.
A refusal is a slowdown, not a loss
A request over a limit is answered with 503 and a
Retry-After, and the tracker retries it — the same answer the
server gives when its queue is full. What Sumimi acknowledged, it has.
Watch refusedRate and refusedInflight in
/api/heartbeat: if they climb, the traffic is arriving faster
than the box can take it, and the limits are doing their job.
How much traffic does my site need?
The table a deployment is sized from, measured on a 2 vCPU Linux box (the exact
high-water mark — the server reads its own /proc figure, because
the promise is about the peak). One page view per request (110 bytes), 64
connections, a fresh server for every row.
These numbers want the allocator's line —
MALLOC_MMAP_THRESHOLD_=65536, below. Without it the same box peaks
at 138 / 145 / 208 / 210 MB instead: 12% to 21% more.
| requests a second | the peak RSS | the engine's share | what it fits |
|---|---|---|---|
| 5 000 | 110 MB | 2 MB | the minimum tier, with 146 MB to spare |
| 10 000 | 115 MB | 2 MB | the minimum tier, with 141 MB to spare |
| 20 000 | 182 MB | 3 MB | the minimum tier, with 74 MB to spare |
| 40 000 | 182 MB | 3 MB | the minimum tier — and the box is at its ceiling |
| 100 000 | about 850 MB | — | neither — it needs a bigger box |
| 200 000 | about 600 MB, and it stops growing | — | the traffic is refused, not stored |
The rule stops applying at the top: 100 000, 150 000 and 200 000 requests a second all land at about 600 MB, because the server parses roughly 113 000 requests a second and no more — everything above that is refused. So 200 000 requests a second is not a "more memory" regime, it is a "most of it is turned away" regime: a real 200 000 needs a stronger box and a faster write path, not more RAM.
The peak is the engine's limit, plus about 50 MB
The peak is not the traffic's memory — it is DuckDB's ingestion, and the
engine's own limit bounds it. Measured at 20 000/s: a 244 MB limit peaks at
215 MB, 128 MB at 178.8 MB, 96 MB at 148.6 MB, 64 MB at 108 MB. So the
rule for sizing a box is
the engine's memory_limit plus about 50 MB, and not
more than the tier. The default limit is a quarter of the machine's
RAM, capped at 256 MB — right for the box it runs on, and on a small box it
is set explicitly.
The size of an event counts as much as the number of them. At 20 000 requests a second, changing only the size of one event:
| one event | the payload | the peak RSS |
|---|---|---|
| 110 bytes (a page view) | 2 MB/s | 275 MB |
| 1 KB | 20 MB/s | 407 MB |
| 10 KB | 200 MB/s | 1812 MB — and the queue overflowed |
The number of connections makes no difference (16, 64 and 256 give the same peak), and what the write path can absorb is counted in bytes a second, not in rows: at 200 MB/s of events the disk cannot keep up, the queue fills, and the server starts refusing — which is the right answer, because the traffic is arriving faster than the disk can take it.
Set the limit to your rate, and the peak is the table's row
Put the rate your site expects into SUMIMI_MAX_ADMIT_RPS and the
process stays at the size the table says, whatever the traffic does. A burst
above that rate is refused with a Retry-After instead of taking
the box with it.
Does this depend on the machine?
The same sweep, the same release, the same tool, back to back — on a 2 vCPU AMD EPYC box, on a 10-core Apple M5, and on that same M5 with the server in a Linux container (which is how most deployments run).
| requests a second | the 2 vCPU EPYC | the 10-core M5 | the M5, in a container |
|---|---|---|---|
| 5 000 | 138 MB | 139 MB | 106 MB |
| 10 000 | 145 MB | 147 MB | 116 MB |
| 20 000 | 208 MB (the box served 19 656/s) | 246 MB | 213 MB |
| 40 000 | 210 MB (served 19 281/s) | 270 MB | 270 MB (served 39 107/s) |
| what the machine could serve | about 19 700 /s | more than 40 000 /s | 40 000 /s |
The memory a request costs is the same on every machine — at the rates the small box can actually serve: 138 MB, 139 MB and 106 MB at 5 000 requests a second. What differs is how much traffic the box can take: a stronger CPU does not mean less memory, it means the same memory serves more traffic. So sizing a box is two questions — how much traffic do you get, and can this machine take it?
The container column is the image as it ships, with the allocator's line; the other two have none, so they are the same variant and can be compared with each other. That makes the second and third columns the same Apple M5 with and without it — and the difference, 139 MB against 106 MB, is what that one line of the environment file is worth. A Linux container gets it for free; macOS does not have it at all (that is Apple's allocator, not glibc's).
Above the small box's ceiling its column stops being comparable: it never served 20 000 requests a second, so it did less work, and its peak is lower because it finished less — not because it was more frugal. Compare at the rates each machine can serve: at 40 000 requests a second the container peaked at 270 MB while serving 39 107/s, and the two-core box peaked at 182 MB while serving 18 400/s. The CPU decides how far up the rate axis you can go; the memory decides what that costs.
Three things to keep in mind. These are two machines, not a law: a
third may well disagree, and what to check on your own box is the rate and the
size of your events — the memory follows from those. Every number here was
measured over loopback, with no TLS: that does not change the
memory a request costs, but a real deployment pays for the handshake and the
encryption, so it can take fewer requests a second than the table suggests. And
the macOS column is the less trustworthy of the three: it is the kernel's
ru_maxrss, which counts the shared pages of the mapped binary and
does not repeat from one round to the next (the same binary at 5 000 requests a
second measured 104 MB in one round and 133 MB in the next), while the Linux
figures are the kernel's exact high-water mark and repeat to within 2 MB.
If the server is near its limit
- Read
/api/heartbeat.queuedanddroppedsay whether the writer is keeping up;refusedRatesays whether the gate is turning traffic away. - Lower
SUMIMI_MAX_ADMIT_RPSfirst — it costs the least and protects the most. - Give DuckDB less room if the engine is the one that grew:
SUMIMI_DUCKDB_MEMORY_LIMIT. It is a ceiling, not an allocation, so lowering it costs speed, not data. - Shorten the retention window if the file is what fills the disk:
SUMIMI_RETENTION_DAYS.
The measurements behind this page, and the design in full, are in the repository: MEMORY.md.
Sumimi / 数咪咪 is an independent open-source project. It is an open-source alternative to Umami and implements the Umami v2 HTTP API; no Umami assets are used. Licensed under the Apache-2.0 licence. Third-party names are referenced nominatively.