How to Optimize DNS Cache to Save Storage Space: A Case Study
What DNS Cache Optimization Actually Looks Like at Scale
When Cloudflare published their deep-dive on how they freed roughly 100 terabytes of memory by reworking the DNS cache layout in 1.1.1.1, the story got a lot of attention for the headline number. But buried inside that post is something far more instructive for working developers: a masterclass in how careful attention to data structure layout, memory alignment, and type choices can compound into massive real-world savings — even in a language like Rust that already gives you fine-grained control.
This article isn't a recap of Cloudflare's blog post. Instead, it uses that work as a jumping-off point to explain the underlying techniques — struct layout optimization, enum size reduction, cache-friendly data structures, and DNS-specific design decisions — in a way you can apply to your own systems. Whether you're building a DNS resolver, an in-memory cache for a web service, or any system where millions of small objects live in memory simultaneously, these ideas matter.
Why DNS Cache Memory Efficiency Is a Hard Problem
DNS resolvers cache responses to avoid redundant upstream queries. At 1.1.1.1's scale — handling hundreds of billions of DNS queries per day — the cache holds an enormous number of entries simultaneously. Each entry needs to store:
- The domain name (variable length)
- The record type (A, AAAA, CNAME, MX, TXT, etc.)
- The TTL (time-to-live) and expiry timestamp
- The actual record data (IP addresses, hostnames, priority values)
- Metadata like whether the entry is negative (NXDOMAIN), whether it's expired but stale, and so on
When you multiply even a modest 200-byte per-entry overhead by hundreds of millions of cache slots, you're already measuring memory in terabytes. Shaving 56% off that — as Cloudflare did — isn't a minor win. It's the difference between needing additional hardware and squeezing dramatically more cache capacity out of existing machines.
The root causes of bloated cache entries are almost always the same: unnecessary padding from struct alignment, oversized types for fields that don't need them, enum variants that force the largest possible discriminant size, heap allocations where stack allocations would do, and abstractions that add indirection without adding value.
Technique 1: Struct Field Ordering and Alignment Padding
In Rust (and C/C++), the compiler aligns struct fields to their natural alignment boundaries. A u64 must be 8-byte aligned, a u32 must be 4-byte aligned, and so on. If you place a u8 before a u64, the compiler inserts up to 7 bytes of padding to satisfy the alignment requirement of the u64.
Consider this naive struct:
struct DnsRecord {
record_type: u8, // 1 byte
// 7 bytes padding
ttl: u64, // 8 bytes
flags: u8, // 1 byte
// 7 bytes padding
expiry: u64, // 8 bytes
// Total: 32 bytes
}
By reordering fields from largest to smallest alignment requirement, you eliminate the padding entirely:
struct DnsRecord {
ttl: u64, // 8 bytes
expiry: u64, // 8 bytes
record_type: u8, // 1 byte
flags: u8, // 1 byte
// 6 bytes padding (only at the end, for struct alignment)
// Total: 18 bytes → rounds to 24 bytes
}
That's a reduction from 32 bytes to 24 bytes on a struct this simple. At millions of instances, that compounds fast. You can use #[repr(C)] to get deterministic layout, or better yet, use the memoffset crate or std::mem::size_of to audit your structs during development.
A practical tip: add a test to your codebase that asserts the size of your hot-path structs:
#[test]
fn dns_record_size_budget() {
assert!(
std::mem::size_of::<DnsRecord>() <= 24,
"DnsRecord has grown beyond its size budget"
);
}
This prevents accidental size regressions as the codebase evolves.
Technique 2: Shrinking Enums by Reducing Variant Data
Rust enums are sized to accommodate their largest variant. If you have an enum like this:
enum RecordData {
A(std::net::Ipv4Addr), // 4 bytes
AAAA(std::net::Ipv6Addr), // 16 bytes
CNAME(String), // 24 bytes (pointer + length + capacity)
MX { priority: u16, host: String }, // 2 + 24 + padding = 32 bytes
Negative, // 0 bytes
}
Every variant — including A and Negative — will occupy at least 32 bytes plus the discriminant. If the majority of your cache entries are A records (which they are in most real-world DNS workloads), you're paying 32 bytes for data that only needs 4 bytes.
The fix is to box large variants or use a separate storage strategy for them:
enum RecordData {
A(std::net::Ipv4Addr), // 4 bytes
AAAA(std::net::Ipv6Addr), // 16 bytes
CNAME(Box<String>), // 8 bytes (just a pointer)
MX(Box<MxData>), // 8 bytes (just a pointer)
Negative, // 0 bytes
}
Now the enum is sized to its second-largest variant (16 bytes for AAAA) rather than 32 bytes. For workloads dominated by A and AAAA records, this is a significant win. The tradeoff is an extra heap allocation for CNAME and MX records, but since those are rarer, the net memory usage drops considerably.
Cloudflare's approach went further — they analyzed the actual distribution of record types in their cache and made targeted decisions about which variants justified boxing and which didn't. That kind of data-driven optimization is only possible if you're measuring your cache composition in production.
Technique 3: Compact Representations for Domain Names
Domain names are variable-length strings, and naively storing them as String in Rust means a 24-byte fat pointer (pointer + length + capacity) plus a heap allocation for the actual bytes. For a cache with hundreds of millions of entries, that's a lot of heap fragmentation and pointer chasing.
Several approaches can help here:
Inline small strings: Most domain names are under 64 bytes. A small-string optimization (SSO) stores short strings inline in the struct itself, avoiding heap allocation entirely. The smol_str and compact_str crates implement this for Rust.
Interning: If the same domain appears in thousands of cache entries (which it will for popular domains), you can intern the string — store it once in a global table and reference it by index. This turns a 24-byte pointer into a 4-byte or 8-byte integer.
DNS wire format: DNS has its own compact encoding for domain names, using label compression to avoid repeating common suffixes. Storing names in wire format and decoding on access trades CPU for memory.
For a DNS cache specifically, a combination of interning for popular domains and inline storage for everything else tends to work well.
Technique 4: Representing Timestamps Without Full u64 Precision
TTL values in DNS are specified in seconds, with a maximum value of about 2.1 billion seconds (the maximum value of a 32-bit unsigned integer). Yet it's tempting to store expiry timestamps as u64 Unix timestamps in nanoseconds, because that's what std::time::Instant gives you.
If you only need second-level precision for TTL expiry, you can store timestamps as u32 seconds since some epoch — perhaps the process start time rather than Unix epoch, which gives you about 136 years of range from any start point. That halves your timestamp storage from 8 bytes to 4 bytes.
struct CacheEntry {
expiry_secs_since_start: u32, // 4 bytes instead of 8
stale_secs_since_start: u32, // 4 bytes for stale-while-revalidate
// ...
}
This kind of type downsizing feels risky — what if the process runs for more than 136 years? — but in practice, DNS resolvers restart, deploy, and upgrade far more frequently than that. The savings are real and the risk is theoretical.
Technique 5: Cache-Friendly Data Structure Layout
Beyond individual struct sizes, the layout of how entries are organized in memory affects performance through CPU cache behavior. A hash map of Box<DnsEntry> means each lookup involves at least one pointer dereference to a heap location that might be anywhere in memory. At high query rates, this causes frequent cache misses at the CPU level.
A more cache-friendly approach is to use an open-addressing hash map where entries are stored inline in a contiguous array. Rust's hashbrown crate (which backs std::collections::HashMap) does this already, but only if your values are stored by value rather than behind a pointer.
For DNS caches specifically, there are additional tricks:
Sharding: Divide the cache into N independent shards, each protected by its own lock. This reduces contention under concurrent access and improves locality since each shard fits better in CPU cache.
Separate hot and cold data: Store frequently accessed fields (expiry, record type, IP address for A records) in one array, and rarely accessed fields (full domain name, extended metadata) in a separate array indexed by the same slot. This improves the density of useful data in each cache line.
Generational eviction: Instead of tracking per-entry LRU state (which adds memory and pointer overhead), use a generational scheme where entries are evicted in bulk when a generation expires. This simplifies the data structure significantly.
Measuring Your DNS Infrastructure
If you operate your own DNS infrastructure — whether it's an authoritative nameserver, a recursive resolver, or just the DNS configuration for your web service — you need visibility into what's actually happening. Memory optimization is only possible when you can measure before and after.
For checking the health and configuration of your DNS setup, the DNS Lookup tool from OpDeck lets you inspect DNS records for any domain, verify propagation, and audit your record configuration without needing to reach for dig or nslookup. This is particularly useful when you're debugging TTL values — a common source of both performance issues and cache inefficiency. If you're setting TTLs too low, you're generating unnecessary upstream queries; too high, and stale data lingers longer than it should.
For reverse DNS lookups — mapping IP addresses back to hostnames, which is essential for auditing your infrastructure and verifying that your servers are presenting the right PTR records — the Reverse DNS Lookup tool handles this quickly without manual command-line work.
And if your DNS is running behind Cloudflare (which, given the context of this article, seems relevant), the Cloudflare Detection tool can confirm whether a domain is proxied through Cloudflare's network and identify related configuration details.
Applying These Lessons Beyond DNS
The five techniques Cloudflare applied — struct field reordering, enum variant boxing, compact string representation, timestamp downsizing, and cache-friendly layout — aren't DNS-specific. They apply anywhere you're managing large numbers of small objects in memory:
- HTTP response caches storing headers, status codes, and body metadata
- Session stores holding authentication tokens and user state
- In-memory databases or key-value stores
- Connection pools tracking socket state and metadata
- Rate limiters maintaining per-client counters and timestamps
The pattern is always the same: profile first, identify the hot data structures, measure their sizes, understand the distribution of values in production, and then apply targeted optimizations based on what you actually see rather than what you assume.
Tools for Auditing Memory Layout in Rust
Beyond the size_of trick mentioned earlier, several tools help you understand and optimize memory layout in Rust:
cargo-bloat: Identifies which parts of your binary are largest, useful for finding unexpectedly large typesheaptrackormemory-profiler: Profiles heap allocations at runtime to show where memory is actually being useddhat(via Valgrind): Detailed heap profiling with allocation site tracking#[repr(C)]+offsetof: Gives you deterministic, inspectable struct layout for cross-language or serialization purposes
For web services specifically, tracking memory usage over time via metrics (Prometheus, Datadog, etc.) and alerting on unexpected growth is essential. A struct that grows by 8 bytes due to a refactor might seem trivial, but at scale it can mean gigabytes of unexpected memory pressure.
The Broader Lesson: Scale Amplifies Everything
What makes Cloudflare's optimization story compelling isn't the specific techniques — it's the demonstration that careful, methodical engineering at the data structure level still matters enormously, even in a language like Rust that's already designed for efficiency.
At 1.1.1.1's scale, 100 terabytes of freed memory translates to real hardware costs avoided, real latency improvements from better cache utilization, and real reliability improvements from reduced memory pressure. But the same principles apply at much smaller scales: a web service handling 10,000 requests per second that shaves 50 bytes off its session cache entry size frees hundreds of megabytes that can be used for more productive work.
The discipline of thinking carefully about data layout, measuring sizes explicitly, and matching your data types to your actual value ranges rather than defaulting to the largest convenient type is what separates systems that run efficiently from those that constantly need more hardware to keep up.
Conclusion
DNS cache optimization at Cloudflare's scale is a compelling case study, but the real takeaway is a set of portable, practical techniques for reducing memory usage in any high-throughput system: eliminate struct padding through field reordering, box large enum variants, use compact representations for variable-length data, downsize timestamps to the minimum required precision, and organize data structures for CPU cache friendliness.
If you're working on a web service and want to understand your current infrastructure health before diving into optimization work, OpDeck's suite of tools — including DNS Lookup, SSL Certificate Checker, and SEO Audit — gives you a fast, practical starting point for auditing what you have before deciding what to change. Good optimization starts with good measurement, and that's true whether you're saving 100 terabytes at Cloudflare's scale or a few gigabytes on your own infrastructure.