Most developers interact with DNS through high-level APIs like dns.resolve() or fetch(), where the protocol details are hidden behind a function call. In this tutorial, I'm going to strip all of that away and build a DNS resolver from scratch in Node.js using nothing but built-in modules: dgram for sending UDP packets and Buffer for constructing and parsing raw binary data.
No npm packages. No third-party libraries. Just you, a UDP socket, and the binary wire format defined in RFC 1035.
By the end of this tutorial, you'll have a working resolver that can query any DNS server for A, AAAA, MX, CNAME, NS, TXT, and SOA records. More importantly, you'll understand exactly what happens in those 12 bytes of header, how domain names get encoded into labels, and how DNS name compression works at the byte level. If you want a conceptual overview first, I recommend reading How DNS Queries Work before diving in here.
DNS Packet Structure Recap
Every DNS message — query or response — follows the same binary format defined in RFC 1035, Section 4.1:
+---------------------+
| Header | 12 bytes, always present
+---------------------+
| Question | Variable length
+---------------------+
| Answer | Variable length (response only)
+---------------------+
| Authority | Variable length (response only)
+---------------------+
| Additional | Variable length (response only)
+---------------------+
The header is always exactly 12 bytes. It contains a 16-bit transaction ID (so you can match responses to queries), flags (including the recursion-desired bit), and four 16-bit counts telling you how many entries are in each subsequent section.
The question section contains the domain name you're asking about, the record type (A, MX, etc.), and the query class (almost always IN for Internet).
The answer, authority, and additional sections contain resource records. These are only populated in responses — our query will have zero entries in all three.
Let's build this up, piece by piece.
Record Type Constants
First, I'll define the record types my resolver will support. DNS record types are just integers on the wire, so I need a mapping between human-readable names and their numeric values:
const RECORD_TYPES = {
A: 1,
AAAA: 28,
CNAME: 5,
MX: 15,
NS: 2,
TXT: 16,
SOA: 6,
};
const RECORD_TYPE_NAMES = Object.fromEntries(
Object.entries(RECORD_TYPES).map(([k, v]) => [v, k])
);
RECORD_TYPE_NAMES is the reverse mapping — given a numeric type from a response, I can look up its human-readable name. This will be useful when parsing answers.
Encoding a Domain Name
DNS doesn't transmit domain names as dot-separated strings. Instead, each label (the part between dots) is prefixed with a single byte indicating its length, and the entire name is terminated with a zero byte. So example.com becomes:
07 65 78 61 6d 70 6c 65 03 63 6f 6d 00
7 e x a m p l e 3 c o m (null terminator)
Here's the encoding function:
function encodeDomainName(domain) {
const parts = domain.replace(/\.$/, "").split(".");
const buffers = parts.map((part) => {
const label = Buffer.from(part);
return Buffer.concat([Buffer.from([label.length]), label]);
});
return Buffer.concat([...buffers, Buffer.from([0])]);
}
The replace(/\.$/, "") strips a trailing dot if present (fully-qualified domain names end with a dot, but I don't want to encode an empty label for it). Each label gets a length byte prepended, and the whole thing ends with 0x00.
Building the Query Packet
Now I can construct a complete DNS query packet. The header is 12 bytes of carefully positioned fields, followed by the encoded question:
import crypto from "crypto";
function buildQuery(domain, recordType = 1) {
const id = crypto.randomBytes(2).readUInt16BE(0);
const header = Buffer.alloc(12);
header.writeUInt16BE(id, 0); // Transaction ID
header.writeUInt16BE(0x0100, 2); // Flags: RD=1
header.writeUInt16BE(1, 4); // QDCOUNT: 1 question
header.writeUInt16BE(0, 6); // ANCOUNT
header.writeUInt16BE(0, 8); // NSCOUNT
header.writeUInt16BE(0, 10); // ARCOUNT
const name = encodeDomainName(domain);
const question = Buffer.alloc(4);
question.writeUInt16BE(recordType, 0); // QTYPE
question.writeUInt16BE(1, 2); // QCLASS: IN
return { packet: Buffer.concat([header, name, question]), id };
}
A few things worth noting:
- Transaction ID is a random 16-bit integer. The server echoes it back in the response, so I can verify I'm reading the right reply. Using
crypto.randomBytesinstead ofMath.randomis important here — predictable transaction IDs are a known attack vector for DNS cache poisoning. - Flags
0x0100sets the Recursion Desired (RD) bit. This tells the server "please resolve this fully for me" rather than just returning a referral to another nameserver. - QDCOUNT = 1 because I'm asking one question. The other counts are zero — this is a query, not a response.
- QCLASS = 1 is the IN (Internet) class. You'll almost never use anything else.
Sending the Query Over UDP
DNS queries are sent over UDP on port 53. Node.js has the dgram module for exactly this. I'll wrap it in a Promise so I can await the response:
import dgram from "dgram";
function sendQuery(packet, server = "8.8.8.8", timeout = 5000) {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket("udp4");
const timer = setTimeout(() => {
socket.close();
reject(new Error("DNS query timed out"));
}, timeout);
socket.on("message", (response) => {
clearTimeout(timer);
socket.close();
resolve(response);
});
socket.on("error", (err) => {
clearTimeout(timer);
socket.close();
reject(err);
});
socket.send(packet, 53, server);
});
}
The function sends my binary packet to port 53 of the specified DNS server (defaulting to Google's 8.8.8.8), then listens for the response. A 5-second timeout prevents it from hanging indefinitely if the server doesn't respond. The socket is closed as soon as I get a response — I don't want to leave UDP sockets open.
Parsing the Response Header
When the response comes back, it starts with the same 12-byte header structure. I need to extract the flags and section counts to know how to parse the rest:
function parseHeader(response) {
return {
id: response.readUInt16BE(0),
flags: response.readUInt16BE(2),
qr: (response.readUInt16BE(2) >> 15) & 1,
aa: (response.readUInt16BE(2) >> 10) & 1,
tc: (response.readUInt16BE(2) >> 9) & 1,
rcode: response.readUInt16BE(2) & 0xf,
qdcount: response.readUInt16BE(4),
ancount: response.readUInt16BE(6),
nscount: response.readUInt16BE(8),
arcount: response.readUInt16BE(10),
};
}
The flags field packs a lot of information into 16 bits. The ones I care about most:
- QR (bit 15): 1 means this is a response. Should always be 1 for incoming messages.
- AA (bit 10): Authoritative Answer. 1 means the responding server is authoritative for the domain.
- TC (bit 9): Truncated. 1 means the response was too large for UDP and I should retry over TCP. My resolver won't handle this, but a production one must.
- RCODE (bits 0-3): Response code. 0 means success (NOERROR), 3 means the domain doesn't exist (NXDOMAIN).
Decoding Compressed Domain Names
This is where DNS gets clever — and where most beginner implementations break. Domain names in responses can use name compression to save space. Instead of repeating example.com in every answer record, the server stores it once and uses a two-byte pointer to reference it elsewhere.
The compression scheme works like this: if the two high bits of a label length byte are both set (0xC0), the remaining 14 bits form a pointer to another position in the packet where the rest of the name can be found.
function decodeDomainName(response, offset) {
const labels = [];
let jumped = false;
let maxOffset = offset;
while (true) {
if (offset >= response.length) break;
const length = response[offset];
if (length === 0) {
if (!jumped) maxOffset = offset + 1;
break;
}
if ((length & 0xc0) === 0xc0) {
if (!jumped) maxOffset = offset + 2;
const pointer = response.readUInt16BE(offset) & 0x3fff;
offset = pointer;
jumped = true;
} else {
offset += 1;
labels.push(response.slice(offset, offset + length).toString());
offset += length;
}
}
return { name: labels.join("."), offset: maxOffset };
}
The jumped flag is critical. When I follow a compression pointer, my read position jumps to a completely different part of the packet. But the next record in the response starts right after the pointer, not after wherever the pointer led me. So maxOffset tracks where the next record begins, while offset follows the pointer chain to reconstruct the name.
For example, if the packet contains:
Offset 12: 07 example 03 com 00 (full name "example.com")
Offset 40: C0 0C (pointer to offset 12)
At offset 40, I see 0xC00C. The high bits 0xC0 tell me this is a pointer. The remaining bits 0x000C (decimal 12) tell me to jump to offset 12 and read the name from there. But the next record starts at offset 42 (right after the 2-byte pointer), not at offset 24 (where the name at offset 12 ends).
Parsing Resource Records
Each answer record has a fixed structure: name, type, class, TTL, data length, and then the record-specific data. Parsing the data depends on the record type:
function parseRecord(response, offset) {
const { name, offset: newOffset } = decodeDomainName(response, offset);
offset = newOffset;
const type = response.readUInt16BE(offset);
const cls = response.readUInt16BE(offset + 2);
const ttl = response.readUInt32BE(offset + 4);
const rdlength = response.readUInt16BE(offset + 8);
const rdataOffset = offset + 10;
let value;
if (type === 1) { // A
value = Array.from(response.slice(rdataOffset, rdataOffset + 4)).join(".");
} else if (type === 28) { // AAAA
const parts = [];
for (let i = 0; i < 16; i += 2) {
parts.push(response.readUInt16BE(rdataOffset + i).toString(16));
}
value = parts.join(":");
} else if (type === 2 || type === 5) { // NS, CNAME
value = decodeDomainName(response, rdataOffset).name;
} else if (type === 15) { // MX
const priority = response.readUInt16BE(rdataOffset);
const exchange = decodeDomainName(response, rdataOffset + 2).name;
value = `${priority} ${exchange}`;
} else if (type === 16) { // TXT
const txtLen = response[rdataOffset];
value = response.slice(rdataOffset + 1, rdataOffset + 1 + txtLen).toString();
} else {
value = response.slice(rdataOffset, rdataOffset + rdlength).toString("hex");
}
return {
record: { name, type, class: cls, ttl, value, typeName: RECORD_TYPE_NAMES[type] || String(type) },
offset: rdataOffset + rdlength,
};
}
Each record type encodes its data differently:
- A records are simply 4 bytes — one per octet of the IPv4 address.
[93, 184, 216, 34]becomes93.184.216.34. - AAAA records are 16 bytes — eight 16-bit groups in hexadecimal, separated by colons.
- NS and CNAME records contain a domain name, which may itself use compression pointers.
- MX records start with a 16-bit priority value, followed by the mail exchange domain name.
- TXT records are length-prefixed strings. The first byte is the string length, followed by that many bytes of text.
- For any type I don't explicitly handle, I fall back to a hex dump of the raw data.
Putting It All Together
Now I can assemble a complete resolve function that ties everything together — build a query, send it, and parse the response:
async function resolve(domain, recordType = "A", server = "8.8.8.8") {
const typeNum = RECORD_TYPES[recordType.toUpperCase()] || 1;
const { packet, id } = buildQuery(domain, typeNum);
const response = await sendQuery(packet, server);
const header = parseHeader(response);
if (header.id !== id) throw new Error("Transaction ID mismatch");
if (header.rcode === 3) throw new Error(`NXDOMAIN: ${domain} does not exist`);
if (header.rcode !== 0) throw new Error(`DNS error: RCODE ${header.rcode}`);
// Skip question section
let offset = 12;
for (let i = 0; i < header.qdcount; i++) {
const { offset: newOffset } = decodeDomainName(response, offset);
offset = newOffset + 4; // QTYPE + QCLASS
}
// Parse answers
const records = [];
for (let i = 0; i < header.ancount; i++) {
const { record, offset: newOffset } = parseRecord(response, offset);
records.push(record);
offset = newOffset;
}
return records;
}
The function first verifies the transaction ID matches — if it doesn't, someone might be trying to inject a spoofed response. Then it checks the response code for errors. After that, it skips past the question section (which the server echoes back) and parses each answer record.
Note that I'm only parsing the answer section here. A more complete implementation would also parse the authority and additional sections, which can contain useful information like the authoritative nameservers and their IP addresses.
Testing It
Save everything in a single file (say dns-resolver.mjs — the .mjs extension enables ES module syntax) and add some test queries at the bottom:
// Resolve A records
const records = await resolve("google.com", "A");
for (const r of records) {
console.log(`${r.name} ${r.ttl} ${r.typeName} ${r.value}`);
}
// MX records
const mx = await resolve("gmail.com", "MX");
for (const r of mx) {
console.log(`${r.name} ${r.ttl} ${r.typeName} ${r.value}`);
}
Run it with:
node dns-resolver.mjs
You should see output like:
google.com 300 A 142.250.80.46
gmail.com 3600 MX 5 gmail-smtp-in.l.google.com
gmail.com 3600 MX 10 alt1.gmail-smtp-in.l.google.com
gmail.com 3600 MX 20 alt2.gmail-smtp-in.l.google.com
gmail.com 3600 MX 30 alt3.gmail-smtp-in.l.google.com
gmail.com 3600 MX 40 alt4.gmail-smtp-in.l.google.com
Try querying different servers to see how responses vary. You can compare your resolver's output against what the DNS Inspector shows for the same domain, or use the Propagation Checker to see how records look from resolvers around the world:
// Query Cloudflare instead of Google
const cf = await resolve("example.com", "A", "1.1.1.1");
console.log(cf);
// Query your ISP's resolver
const isp = await resolve("example.com", "AAAA", "your-isp-resolver-ip");
console.log(isp);
// TXT records (SPF, DKIM, DMARC)
const txt = await resolve("google.com", "TXT");
for (const r of txt) {
console.log(`${r.typeName}: ${r.value}`);
}
What's Missing from a Production Resolver
This tutorial builds a working resolver, but a production-grade implementation needs several more features:
- TCP fallback. If a UDP response has the TC (truncated) flag set, the full answer didn't fit in a single UDP packet (limited to 512 bytes without EDNS, ~4096 with). A real resolver retries the query over TCP, where the response can be any size.
- EDNS0. Extension mechanisms for DNS (RFC 6891) allow the client to advertise support for larger UDP payloads. Without it, servers may truncate responses unnecessarily. The OPT pseudo-record goes in the additional section.
- Response caching. Each record has a TTL. A production resolver caches answers and reuses them until the TTL expires, avoiding redundant queries. This is the core of what makes DNS fast.
- Connection pooling and pipelining. Opening a new UDP socket for every query is wasteful at scale. Production resolvers maintain socket pools and can send multiple queries without waiting for each response.
- DNSSEC validation. My resolver trusts whatever the server sends back. A validating resolver checks RRSIG, DNSKEY, and DS records to verify the response hasn't been tampered with.
For most application-level code, you should use Node.js's built-in dns module rather than rolling your own. I cover when to use dns.lookup() versus dns.resolve() — and the important differences between them — in DNS Queries in Node.js: dns.lookup vs dns.resolve.
Further Reading
If you want to dig deeper into the protocol and tooling around DNS:
- How DNS Queries Work covers the full resolution chain from stub resolver to root servers.
- The dig Command Guide shows you how to use the standard command-line tool for DNS queries — useful for verifying your resolver's output.
- DNS Queries in Node.js: dns.lookup vs dns.resolve explains the two built-in approaches and when each one is appropriate.
- The DNS Inspector runs 25+ automated checks on any domain's DNS configuration, so you can see how production DNS setups look in practice.
