Build a DNS Resolver Cache
DNS Resolver Cache — a complete walkthrough of dns resolution, covering record cache, ttl scheduler, negative cache, and testing.
Project goal
Implement dns resolution that solves caching resolved records with correct TTL handling and negative caching to avoid hammering upstream resolvers, with each component independently testable.
Final features
- Record cache
- TTL scheduler
- Negative cache
- Prefetch warmer
- Resolution pipeline
Architecture
The system is organized around record cache, ttl scheduler, negative cache, and prefetch warmer ties them together. Data flows as a cache of DNS records keyed by name and type, each entry expiring at its TTL with negative answers cached briefly.
Prerequisites
- Node.js 20 LTS or later
- Familiarity with dns resolution fundamentals
- A local environment to run the test suite
Dependencies
- dns-packet ^5.6.0 — DNS wire format
- dgram built-in — UDP transport
Directory structure
dns_resolver_cache/
├── src/index.ts
├── src/record-cache.ts
├── src/ttl-scheduler.ts
├── tests/dns_resolver_cache.test.ts
└── package.json
Phases
1. Record cache
Build the record cache that anchors dns resolution and defines the core data shape.
// Record cache: dns resolution core
export function recordcache(input: RecordcacheInput): RecordcacheResult {
// Build the record cache that anchors dns resolution and defines the core data shape.
return processRecordcache(input);
}
Expected behavior: After this phase the record cache works in isolation and is covered by a unit test.
2. TTL scheduler
Add the ttl scheduler so dns resolution can caching resolved records with correctly.
// TTL scheduler — handles dns resolution Add the ttl scheduler so
export class Ttlscheduler {
constructor(private opts: TtlschedulerOptions) {}
async run(ctx: Context): Promise<TtlschedulerOutput> {
return this.execute(ctx);
}
}
Expected behavior: After this phase the ttl scheduler works in isolation and is covered by a unit test.
3. Negative cache
Wire the negative cache to handle a popular name whose without losing data.
// Negative cache: dns resolution stage
export const negativecache = (next: Next) => async (req: Request) => {
const out = await transform(req);
return next(out);
};
Expected behavior: After this phase the negative cache works in isolation and is covered by a unit test.
4. Prefetch warmer
Integrate the prefetch warmer to make dns resolution observable and testable end to end.
// Prefetch warmer for dns resolution
export async function prefetchwarmer(stream: AsyncIterable<Chunk>): Promise<Aggregate> {
let acc = init();
for await (const c of stream) acc = merge(acc, c);
return acc;
}
Expected behavior: After this phase the prefetch warmer works in isolation and is covered by a unit test.
5. Resolution pipeline
Finalize the resolution pipeline so the dns resolution system degrades gracefully under overload.
// Resolution pipeline: dns resolution finalization
export function resolutionpipeline(state: State): Result {
validate(state);
return finalize(state);
}
Expected behavior: After this phase the resolution pipeline works in isolation and is covered by a unit test.
Testing
Each phase has a focused test: record cache, ttl scheduler, negative cache are exercised in isolation, then an integration test drives the full dns resolution pipeline and asserts the a popular name whose case is handled.
Troubleshooting
A popular name whose TTL expires simultaneously across many instances and produces a thundering herd of lookups
Reproduce with a minimal fixture, then add a guard test so the dns resolution system rejects the condition loudly.
Performance regresses under load
Profile with the sampling analyzer, locate the hot path, and add a targeted benchmark to lock the regression.
Deployment
Run two instances behind a load balancer, share state in Redis, and set resource limits so overload surfaces as backpressure rather than failure.
Known limitations
- Single-region; multi-region replication is out of scope.
- The record cache is in-process and not horizontally shared by default.
- No auth layer; assume deployment behind an edge that enforces identity.
Project overview
This project builds a dns resolution system whose reason for existing is caching resolved records with correct TTL handling and negative caching to avoid hammering upstream resolvers. The temptation in dns resolution is to bolt the interesting part onto a working prototype and discover the hard cases in production; this build inverts that order and treats a popular name whose TTL expires simultaneously across many instances and produces a thundering herd of lookups as the first thing designed, not the last.
You will implement Record cache, TTL scheduler, Negative cache, Prefetch warmer, and Resolution pipeline, each as a module with one responsibility and a test that proves it in isolation. The dns resolution pipeline is then the wiring that composes them, and the integration test is where the composition is shown to survive a popular name whose TTL expires simultaneously across many instances and produces a thundering herd of lookups.
The walkthrough is written for a developer who has used a dns resolution system but has not had to make one honest. It favors explicit data shapes over clever abstractions, and it names the failure each design choice prevents rather than presenting the choice as a style preference.
Each phase ends with something observable — a record cache that returns the right shape, a ttl scheduler that rejects bad input, an integration test that goes green — so you can stop at any point and still have a dns resolution system that works for the cases covered so far.
What you will build
The finished dns resolution system exposes five capabilities, each with a focused test:
- Record cache
- TTL scheduler
- Negative cache
- Prefetch warmer
- Resolution pipeline
Record cache is the foundation that every later stage assumes; TTL scheduler is where dns resolution correctness lives; Negative cache handles a popular name whose TTL expires simultaneously across many instances and produces a thundering herd of lookups; Prefetch warmer makes the system observable; and Resolution pipeline is the graceful-degradation layer that keeps overload from becoming corruption.
The build keeps the dns resolution logic in ordinary modules you can read in one sitting, and pulls in dependencies only for the parts that are genuinely tedious to hand-roll. That keeps the diff between the tutorial and a real codebase small, and keeps the failure modes visible in code rather than hidden behind a framework.
Architecture and data model
The system is organized around Record cache, TTL scheduler, and Negative cache, with Prefetch warmer tying them together. Data flows as a cache of DNS records keyed by name and type, each entry expiring at its TTL with negative answers cached briefly.
Each component is a module with a pure interface where practical: the same input produces the same output, and side effects are pushed to the edges. That discipline is what makes the phases independently testable, because a test for Negative cache does not need to stand up Record cache and TTL scheduler to exercise the interesting branch.
The data model is deliberately explicit. A cache of DNS records keyed by name and type, each entry expiring at its TTL with negative answers cached briefly Keeping the shape written down — in a schema, a type, or a fixture — means a change to the shape is a visible diff rather than a silent runtime shift, and it means the integration test can assert against a concrete shape instead of guessing.
Configuration and code are kept apart: the configuration says what dns resolution should do, the modules say how. A reviewer can change a threshold or a policy without reading the implementation, which keeps the risky changes small and lets the diff stand as the review.
The failure path through Negative cache is designed before the happy path is finished, because a popular name whose TTL expires simultaneously across many instances and produces a thundering herd of lookups is the case that distinguishes a dns resolution system from a demo. Every component declares what it does when its inputs are wrong, and the integration test asserts each of those declarations.
Prerequisites and dependencies
Have these ready before you install anything, so the dns resolution setup is not blocked by a missing tool:
- Node.js 20 LTS or later
- Familiarity with dns resolution fundamentals
- A local environment to run the test suite
The runtime dependencies are chosen to keep the dns resolution datapath legible:
dns-packet(^5.6.0) — DNS wire formatdgram(built-in) — UDP transport
# setup for dns-resolver-cache
pnpm init
pnpm add dns-packet dgram
pnpm add -D typescript tsx vitest
Versions are pinned to a major range so the dns resolution install is reproducible; commit the lockfile. The record cache reads its operational parameters from the environment and validates them at startup, so a missing value fails the boot rather than the first dns resolution request.
Directory structure
Keep each dns resolution component in its own file so it can be tested without starting the others:
dns_resolver_cache/
├── src/index.ts
├── src/record-cache.ts
├── src/ttl-scheduler.ts
├── tests/dns_resolver_cache.test.ts
└── package.json
Phase 1: Record cache
Build the record cache that anchors dns resolution and defines the core data shape.
// Record cache: dns resolution core
export function recordcache(input: RecordcacheInput): RecordcacheResult {
// Build the record cache that anchors dns resolution and defines the core data shape.
return processRecordcache(input);
}
After this phase the record cache works in isolation and is covered by a unit test. The record cache defines the data shape that every later stage assumes, so getting it right first means the later phases compile against a stable contract.
Treat the record cache as the single source of truth for the dns resolution data shape. Validate inputs at the boundary and reject early, so a malformed value never reaches TTL scheduler or Negative cache where its effect would be harder to localize.
Phase 2: TTL scheduler
Add the ttl scheduler so dns resolution can caching resolved records with correctly.
// TTL scheduler — handles dns resolution Add the ttl scheduler so
export class Ttlscheduler {
constructor(private opts: TtlschedulerOptions) {}
async run(ctx: Context): Promise<TtlschedulerOutput> {
return this.execute(ctx);
}
}
After this phase the ttl scheduler works in isolation and is covered by a unit test. This is where dns resolution correctness lives: the ttl scheduler is what makes caching resolved records with correct TTL handling and negative caching to avoid hammering upstream resolvers true, and its test is the proof.
Keep the ttl scheduler pure where you can. A function that takes an input and returns an output is trivial to test and impossible to mock wrong; a class with hidden state is the opposite. Push side effects to the caller and keep the ttl scheduler about the transformation.
Phase 3: Negative cache
Wire the negative cache to handle a popular name whose without losing data.
// Negative cache: dns resolution stage
export const negativecache = (next: Next) => async (req: Request) => {
const out = await transform(req);
return next(out);
};
After this phase the negative cache works in isolation and is covered by a unit test. The negative cache is the difference between a dns resolution system that works on the happy path and one that survives a popular name whose TTL expires simultaneously across many instances and produces a thundering herd of lookups.
Design the failure before the success. Decide what negative cache does when a popular name whose TTL expires simultaneously across many instances and produces a thundering herd of lookups: reject, retry, shed, or degrade — and write that test first. A dns resolution system whose failure behavior is unspecified will pick one at random under pressure, and it is usually the wrong one.
Phase 4: Prefetch warmer
Integrate the prefetch warmer to make dns resolution observable and testable end to end.
// Prefetch warmer for dns resolution
export async function prefetchwarmer(stream: AsyncIterable<Chunk>): Promise<Aggregate> {
let acc = init();
for await (const c of stream) acc = merge(acc, c);
return acc;
}
After this phase the prefetch warmer works in isolation and is covered by a unit test. Observability is not a finishing touch; it is how you know the dns resolution system is doing what it claims in production.
Emit one structured event per meaningful action, with enough context to localize a failure without a second round-trip. The prefetch warmer is what turns “something is slow” into “the ttl scheduler is slow for inputs matching this shape,” which is the difference between an alert and an investigation.
Phase 5: Resolution pipeline
Finalize the resolution pipeline so the dns resolution system degrades gracefully under overload.
// Resolution pipeline: dns resolution finalization
export function resolutionpipeline(state: State): Result {
validate(state);
return finalize(state);
}
After this phase the resolution pipeline works in isolation and is covered by a unit test. The resolution pipeline is what keeps overload from becoming corruption: when the dns resolution system is asked for more than it can do, it degrades in a defined way instead of failing in an undefined one.
Pick a degradation strategy for resolution pipeline — shed, queue, or backpressure — and make it explicit in code and in a test. Leaving the behavior to chance is how a dns resolution traffic spike becomes a data loss incident.
Testing
Each phase has a focused test: record cache, ttl scheduler, and negative cache are exercised in isolation, then an integration test drives the full dns resolution pipeline and asserts the a popular name whose case is handled.
Start with unit tests that pin each component’s contract: the record cache returns the documented shape, the ttl scheduler transforms correctly, and the negative cache fails in the documented way. These tests are fast and they localize regressions to the component that broke.
Then add an integration test that composes the components and drives a realistic dns resolution scenario end to end. Assert the happy path first, then deliberately trigger a popular name whose TTL expires simultaneously across many instances and produces a thundering herd of lookups and assert the system degrades rather than corrupts. Because the components are pure where practical, the integration test stays deterministic rather than flaky.
A dns resolution suite must also prove the negative: bad input is rejected at the boundary, overload is shed rather than queued indefinitely, and a partial failure does not leave the system in an inconsistent state. These are the tests that catch the regressions a happy-path suite never would.
The entire dns resolution suite runs with no external service; every dependency is either in-process or stubbed, so every case is reproducible in CI. That is what lets the negative cache failure-mode assertions live as tests rather than as a manual checklist that drifts out of date.
# run the dns resolution test suite
pnpm vitest run
Deployment and operations
Run at least two dns resolution instances behind a load balancer and share state in Redis. Set resource limits so dns resolution overload surfaces as backpressure rather than failure.
Run at least two stateless instances behind a load balancer, and move any shared state into a shared store so the dns resolution behavior is global rather than per instance. Size limits so the system sheds load before it runs out of memory, because a dns resolution system that OOMs under load fails in a way that is hard to reason about.
Drain on deploy: stop accepting new work, let in-flight dns resolution operations finish, then exit. Because the system holds no state that cannot be reconstructed, a rollout is a matter of cycling instances one at a time while watching the error rate.
When a dependency is unavailable, decide explicitly whether the dns resolution system fails closed or degrades. Leaving that choice to chance is how a dependency outage becomes a silent data loss, so make the choice visible in code and in a test.
Security considerations
Validate all dns resolution inputs at the boundary, reject oversized payloads before buffering them, and keep secrets in the environment rather than the repository.
The dns resolution system is a choke point, so it is the right place to reject malformed input before it reaches ttl scheduler or negative cache. Validate the shape and the size at the edge, and let the inner components assume what they receive is well-formed.
Never log secrets or full payloads; log enough to localize a failure without recording data that should not be on disk. Treat any external call as untrusted and validate its response before using it, because a dns resolution system that trusts its inputs blindly is one malformed response away from corruption.
Keep dependencies pinned and run an audit in CI. A transitive dependency with a known vulnerability is still a vulnerability in your dns resolution system, and the lockfile is what makes the audit meaningful.
Observability
Emit one structured event per dns resolution operation with the inputs’ shape, the outcome, and a correlation id. Track error rate and latency per component and alert on a step change rather than a single spike.
The correlation id is the thread that ties a user-visible symptom to the dns resolution decision and to the component log entry for the same operation. Without it, an investigation starts from a timestamp and a guess.
Watch the negative cache failure rate separately from the overall error rate. A jump in negative cache failures usually means a popular name whose TTL expires simultaneously across many instances and produces a thundering herd of lookups, not a bug in ttl scheduler, and the two have different owners and different fixes.
Chart the share of dns resolution operations that shed, queue, or succeed. A change in the mix points at the cause faster than an aggregate latency can, because each class has a different root.
Troubleshooting
A popular name whose TTL expires simultaneously across many instances and produces a thundering herd of lookups. Reproduce with a minimal fixture, then add a guard test so the dns resolution system rejects the condition loudly.
Performance regresses under dns resolution load. Profile with the sampling analyzer, locate the hot path in ttl scheduler, and add a targeted benchmark to lock the regression.
Keep the dns resolution dashboards separate from dependency dashboards. When an operation fails, the correlation id lets you decide in seconds whether the fault is record cache, ttl scheduler, or negative cache, instead of guessing from a timeout.
Limitations and next steps
- Single-region; multi-region replication is out of scope.
- The record cache is in-process and not horizontally shared by default.
- No auth layer; assume deployment behind an edge that enforces identity.
The dns resolution system is single-region by design; extending to multi-region means making record cache shareable across regions, which is a real change to the consistency model rather than a config flag. The negative cache is in-process, which is correct for one instance but must move to a shared store before scaling out.
There is no authentication or authorization layer; the system assumes it is deployed behind an edge that enforces identity. Adding one is straightforward but out of scope for this build, and doing it here would distract from the dns resolution correctness that is the point.
The natural next step, once the dns resolution pipeline is trusted in production, is to add a short-TTL cache for idempotent reads and a richer prefetch warmer that exports histograms rather than counters. Both are additive and neither changes the contracts this build establishes.