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

tierthe machineDuckDB's ceilingthe whole process
minimum1 vCPU / 512 MB128 MB256 MB
recommended1 vCPU / 1 GB256 MB512 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:

accountwhat it islimited bymeasured
the binarythe executable's text and data, the thread stacksnothing — it is what it is42 MB idle
DuckDB's bufferthe engine's buffer managermemory_limit0.5–7 MB
DuckDB's ingestionthe appender's rows, the write-ahead log, the checkpointmemory_limitthe peak itself: ~150 MB at 20 000/s
the collectorreading, parsing and queueing a requestthe four limits belowmeasured: almost nothing — the SQLite backend does the same 20 000/s in 32.8 MB
the queuethe hits waiting to be writtenSUMIMI_MAX_QUEUE (rows)~5 MB of structs per 10 000 rows, plus the strings
the allocatorthe pages glibc took and did not give backMALLOC_MMAP_THRESHOLD_−12% to −21% of the peak when it is set

The four axes: the sizes as bars, the latency as the line

The report over a range

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

workloadthe peak RSSthe engine holds
idle42 MB0.5 MB
200 000 page views through the write path168 MB (128 MB engine) / 172 MB (256 MB engine)7 MB
a burst: 1 GB injected in 60 s636 MB2 MB
the same burst, another day1560 MB4 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 variablewhat it boundsdefault
SUMIMI_MAX_BODY_BYTESone request's body2 MB
SUMIMI_MAX_INFLIGHT_REQUESTSrequests being read or parsed at onceunlimited
SUMIMI_MAX_INFLIGHT_BYTESthe bytes in flight at onceunlimited
SUMIMI_MAX_ADMIT_RPSrequests admitted per secondthe 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 allocatorthe peakthe change
the default274 MB—
MALLOC_MMAP_THRESHOLD_=65536210 MB−23%
MALLOC_TRIM_THRESHOLD_=65536222 MB−19%
MALLOC_ARENA_MAX=2227 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 allocatorthe peakwhat the container holds after the load
the image as it ships106 MB23 MiB
glibc as it comes141 MB56 MiB
never map anything304 MB178 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 secondthe peak RSSthe engine's sharewhat it fits
5 000110 MB2 MBthe minimum tier, with 146 MB to spare
10 000115 MB2 MBthe minimum tier, with 141 MB to spare
20 000182 MB3 MBthe minimum tier, with 74 MB to spare
40 000182 MB3 MBthe minimum tier — and the box is at its ceiling
100 000about 850 MB—neither — it needs a bigger box
200 000about 600 MB, and it stops growing—the traffic is refused, not stored

The peak against the rate

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 peak against the engine's limit

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 eventthe payloadthe peak RSS
110 bytes (a page view)2 MB/s275 MB
1 KB20 MB/s407 MB
10 KB200 MB/s1812 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 secondthe 2 vCPU EPYCthe 10-core M5the M5, in a container
5 000138 MB139 MB106 MB
10 000145 MB147 MB116 MB
20 000208 MB (the box served 19 656/s)246 MB213 MB
40 000210 MB (served 19 281/s)270 MB270 MB (served 39 107/s)
what the machine could serveabout 19 700 /smore than 40 000 /s40 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

  1. Read /api/heartbeat. queued and dropped say whether the writer is keeping up; refusedRate says whether the gate is turning traffic away.
  2. Lower SUMIMI_MAX_ADMIT_RPS first — it costs the least and protects the most.
  3. 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.
  4. 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.