TL;DR Here's how to reduce Rust struct memory footprint the way Cloudflare's DNS cache team did: swap Vec/String for Box types, consolidate parallel lists, make the record owner optional, box large enum variants, and store hot data as raw wire-format bytes. Together those five changes cut a real cache entry from 953 bytes to 420 bytes (-56%), raised insert throughput 43%, and dropped lookup latency 19%. None of it depends on Cloudflare's specific workload — the same five techniques apply to any Rust struct you cache, pool, or store by the million.
A Rust struct's memory footprint is the total space it occupies — its own fields plus every heap allocation those fields own — and it's almost never what you'd get by adding up each field's logical size. Cloudflare's DNS resolver caches host over 250 billion entries at any given moment, enough scale that a single wasted byte per entry costs the fleet more than 250GB of RAM. When their engineering team went looking for where that byte was hiding, they found 533 of them, sitting quietly in the layout of a struct nobody had re-measured since it was first written.
You don't need Cloudflare's scale to feel this. Any Rust service that caches responses, pools connections, or holds parsed records by the thousand pays the same tax. It's the same instinct behind cutting Node.js heap memory in half with pointer compression or keeping a Rust LSP under 100MB: the savings usually live in layout, not logic. This post walks through the five techniques, in the order to apply them, with the trade-off each one actually costs.
How to reduce Rust struct memory footprint: what actually eats it
Padded, allocation-aware field sizes are the real number, not the sum of logical sizes on paper. Three defaults do almost all of the damage:
Vec of TandStringreserve for growth. Both are three machine words — pointer, length, capacity — because they're designed to be pushed to after creation. If a field is built once and never grows again, that capacity word (8 bytes on a 64-bit target) is dead weight, forever, in every instance.Enums are sized to their largest variant.
size_of for MyEnumis the size of the biggest variant plus a discriminant, not the size of whichever variant you actually have. A single rare 136-byte variant taxes every small, common variant too.Field order creates padding. The compiler aligns each field to its own size, and it inserts padding between fields to satisfy that alignment — reordering fields from largest-to-smallest alignment often removes bytes the source code never asked for.
None of this shows up by reading the struct definition. It shows up when you measure it.
How do you find where a struct wastes memory?
Two tools cover almost every case. std::mem::size_of for your type gives you the stack-resident size — useful as a regression check (static_assertions::assert_eq_size! pins it in CI so nobody silently grows the struct again), but it stops at the first pointer. A Box of [u8] field reports as 16 bytes regardless of whether it points at 4 bytes or 4 kilobytes on the heap, so size_of alone will miss the biggest allocations in a struct built around boxed or vectored fields.
For the full picture, rustc's unstable -Z print-type-sizes flag (nightly only) prints every field's offset, size, and the padding between them:
RUSTFLAGS="-Z print-type-sizes" cargo +nightly build 2>&1 | grep -A 20 "type: `CacheEntry`"
That output is where the surprise bytes actually live — a 4-byte field followed by 4 bytes of alignment padding, an enum discriminant costing more than the data it tags, three Vec fields each carrying a capacity word nothing ever uses. Measure before you touch anything; "it feels smaller" is not a metric.
The 5 techniques that cut a cache entry from 953 bytes to 420 bytes
Cloudflare applied these five changes, in roughly this order, to the CacheEntry/Record structs behind its DNS resolver cache:
| # | Technique | What it removes |
|---|---|---|
| 1 |
Vec of T/String → Box of [T]/Box of str
|
The 8-byte capacity word on fields that never grow after construction |
| 2 | Consolidate parallel lists into one, indexed by u16 offset |
Two extra Vec headers (48 bytes) on top of the one you keep — 28 bytes/entry net |
| 3 |
Option of Box of Name for the record owner |
A full owner allocation when the owner matches the query name, the common case |
| 4 | Box large enum variants (e.g. a 136-byte NAPTR case) |
The tax every small variant pays to fit the largest one |
| 5 | Store records as raw wire-format bytes in one Box of [u8] with length prefixes |
Per-variant struct overhead, plus scattered heap allocations that hurt cache-line locality |
Applied together, the result on Cloudflare's real workload:
| Metric | Before | After | Change |
|---|---|---|---|
| Per-entry footprint | 953 bytes | 420 bytes | -56% |
| Per-entry allocations | 1.1 KB | 461 bytes | -58% |
| Insert throughput | 625,000 entries/s | 893,000 entries/s | +43% |
| Lookup latency | 828 ns | 670 ns | -19% |
Fleet-wide, that footprint cut freed roughly 100 terabytes of RAM — the equivalent of about 130 servers' worth of memory — and dropped measured production p99 memory per instance from 9.3GB to 5.3GB, a 43% reduction. None of the five techniques is exotic. What made the difference was applying all five to one struct instead of stopping after the easy one.
Step-by-step: apply this to your own struct
Measure first. Run
size_of for your typeand, if you can use nightly,-Z print-type-sizesto see where the bytes actually go before changing anything.Swap non-growing
Vec/Stringfields forBox of [T]/Box of str. If a field is set once at construction and never pushed to again, this is a free 8-byte-per-field cut with no behavior change —.to_vec().into_boxed_slice()or.into_boxed_str()at the call site.Look for parallel list fields that can become one list plus an index. Three
Vec of Recordfields for three record sections is three sets of Vec overhead; oneVec of Recordwith au16offset marking where each section starts is one.Make "usually redundant" fields
Option of Box of T. If a field's value equals some other field's value most of the time (an owner name that matches the query, a default that matches a global), storeNonefor the common case and allocate only for the exception.Box the rare, large enum variants. If one variant dominates your struct's size but a small fraction of your instances, box just that variant so the common variants stop paying for it.
Consider raw wire-format storage last, and only if reads are cheap to re-parse. This is the highest-payoff, highest-cost change — it removes per-variant struct overhead entirely, but every read now costs a parse or an index instead of a field access.
Re-measure size, then re-benchmark insert and lookup — not just size_of. A smaller struct that's slower to read is not a win; confirm throughput and latency moved the direction you expect before shipping.
What breaks when you box enum variants?
Boxing is not a free lever — it trades memory for indirection, and indirection has real costs that only show up under load.
Every boxed variant costs a heap allocation and a pointer chase. Where an unboxed enum reads a field directly off the stack or its containing struct, a boxed variant means following a pointer to wherever the allocator put it — worse for CPU cache locality, and a real allocator call on every construction. This is exactly why boxing only pays off when the boxed variant is rare: Cloudflare's NAPTR records are a small slice of traffic behind A/AAAA, which together make up over 80% of what the cache actually serves. Box the common case instead and you've made the fast path slower to save memory nobody was spending.
Wire-format storage has the same shape of trade-off, at a larger scale. Storing records as raw bytes removes struct overhead entirely, but it means every read now parses or indexes into a buffer instead of matching a typed enum. That's a good trade for a cache that's read far more often than it's deeply inspected — exactly the profile of a DNS resolver cache. It's a bad trade for a hot loop that pattern-matches the same field thousands of times per second, where the CPU cost of re-parsing on every access will outweigh whatever memory you saved.
The failure mode to watch for either way: shipping the change, seeing the smaller size_of for your type, and skipping the throughput/latency re-benchmark from step 7. A struct that's 56% smaller and 20% slower under real load is not the win it looks like on paper.
FAQ
Why does Vec of T use more memory than Box of [T] for the same data?
Vec of T stores three machine words — a pointer, a length, and a capacity — because it's built to grow. Box of [T] only stores a pointer and a length, because it can never grow after it's created. On a 64-bit target that's 24 bytes versus 16 bytes per field before you've stored a single element of real data. If a field is populated once and never mutated again, which describes most cache entries, the capacity word is pure waste.
When does boxing an enum variant actually save memory?
Only when the boxed variant is rare relative to the others. A Rust enum is sized to fit its largest variant plus a discriminant, so one 136-byte variant forces every instance of that enum to reserve 136 bytes even if most real values are 4-16 bytes. Boxing the large variant shrinks the enum to roughly the size of the common case plus one pointer, at the cost of a heap allocation whenever that rare variant actually appears.
Does storing data as raw wire-format bytes always save memory?
It saves memory whenever the parsed representation carries overhead the raw bytes don't — enum discriminants, Vec capacity, padding between variant-specific fields. The cost is CPU: every read has to parse or index into the byte buffer instead of matching on an already-typed value. It's a good trade for entries read far more often than they're deeply inspected, and a bad one for hot paths that pattern-match the same field thousands of times per second.
How do I measure a Rust struct's actual memory footprint before optimizing?
Start with std::mem::size_of for your type for the shallow, stack-resident size, then follow every Box, Vec, and String field to add their heap allocations, since size_of alone won't show those. For the full picture including padding, rustc's unstable -Z print-type-sizes flag on nightly prints every field's offset and the padding between them, which is usually where the surprise bytes are hiding.
Is this worth doing for a struct I only have a few thousand of?
Rarely, on its own. The throughput and latency gains in Cloudflare's numbers came alongside the memory cut on a cache with hundreds of billions of entries, where cache-line locality compounds. For a few thousand instances, the bigger win is usually just replacing Vec/String with Box of [T]/Box of str on fields that never grow — it's close to free and doesn't need a rewrite to raw bytes to pay off.
If you're chasing memory across the rest of your stack too, the same pointer-shrinking instinct is behind cutting Node.js heap memory in half with V8 pointer compression, and the same profile-first discipline shows up in sizing GPU memory for local model serving and in tracking down a Linux scheduler latency regression. If you're weighing Rust for the workload in the first place, Rust's safe GPU offload benchmarks are a useful second data point on what the language actually costs and saves at the systems layer.
Sources
- How we saved 100 terabytes of memory by optimizing 1.1.1.1's DNS cache — Cloudflare Blog
- std::boxed::Box documentation — Rust standard library
- Type Layout — The Rust Reference
Originally published at umesh-malik.com
Keep reading on umesh-malik.com: