Diagnostic TTL / Propagation
TTL set too low overloads authoritative nameservers
Someone dropped the TTL to 30 or 60 seconds before a migration so a change would take effect fast, and it worked. Then the migration finished, everyone moved on, and nobody ever set it back. Months later the domain is getting hit with far more DNS queries than it should, nameserver response times are creeping up, and on a busy day it starts to look a lot like an attack. It is not an attack. It is a TTL that never got raised back to normal.
Every DNS answer carries a TTL that tells resolvers how long they may serve it from cache before asking your authoritative nameservers again. Leaving TTL at a migration-era value like 30 to 60 seconds instead of raising it back afterward forces resolvers worldwide to re-query far more often, since query volume is roughly inversely proportional to TTL. Dropping from 3600 seconds to 60 seconds can multiply authoritative query load by about 60 times, which can slow down nameserver responses or trigger rate limiting on a busy domain. Check the live value with dig +noall +answer example.com A, and treat anything under about 120 seconds on a record that is not actively being migrated as a red flag. The fix is to raise the TTL back to a steady value like 3600 seconds (or up to 86400 seconds for records that rarely change), either at the DNS host's UI or through the Cloudflare API. Full commands, records, and a script are below.
The problem in plain words
TTL stands for time to live, and it rides along with every DNS answer. It tells every resolver in the world how many seconds they are allowed to keep that answer before they have to come back and ask your nameservers again. A high TTL means most of the world is reading from cache almost all the time. A low TTL means the cache empties out fast, and resolvers keep coming back for a fresh copy.
During a migration, a short TTL is genuinely useful. If you are about to move a server or switch DNS hosts, a 60 second TTL means the old, wrong answer only lives in caches for about a minute after you flip the record, instead of an hour or more. That is the right tool for that moment. The problem starts when the migration ends and the TTL stays at 60 seconds forever, because nobody remembered to change it back, or nobody realized changing it back mattered.
Why it happens
- A short TTL is set on purpose before a planned cutover, so a bad or wrong record does not linger in caches for long, and that part works exactly as intended.
- The migration finishes, the new record is confirmed working, and everyone moves on to the next task without a step in the runbook that says "revert the TTL."
- Query volume is roughly inversely proportional to TTL, so the effect of forgetting is not linear. Going from 3600 seconds to 60 seconds is not a little more traffic, it is about 60 times more traffic hitting the authoritative nameservers for the same number of real visitors.
- Because the record itself still resolves correctly, nothing looks broken from the outside. The only signal is rising query volume or degraded nameserver response times, which often gets investigated as a DDoS before anyone checks the TTL.
- On some DNS providers a proxied or "Auto" setting quietly forces a low TTL (Cloudflare's proxied records use an effective 300 seconds regardless of what is configured), which can mask the real cause if a team assumes the TTL is fine because they set it once and never checked again.
TTL is not just a propagation-speed knob, it is a load-control knob. RFC 1035 defines it as the number of seconds an answer may be cached before it must be re-fetched from the authoritative source, and that single number sets the ceiling on how often the whole internet's resolvers are allowed to ask you again. A TTL that is too low for the traffic a domain gets is not a cosmetic setting left over from a migration, it is an ongoing cost paid in nameserver load every single day it stays that way.
The fix, as a flow
Read the live TTL from the authoritative nameservers, not just from a public resolver's cached view, and compare it against how busy the domain actually is. If the TTL is well under a safe threshold for the traffic the record sees, and there is no active migration to justify it, raise the TTL back to a normal steady-state value and confirm the change is live at the source.
How to fix it
Read the live TTL from a public answer
Start with a normal lookup to see what most of the world is currently caching. Look at the number in the TTL column, the second value on the answer line, not the record data itself.
dig +noall +answer example.com A
dig +noall +answer example.com CNAME
# a record in trouble looks like this, TTL under 120 seconds
# with no migration in progress:
# example.com. 60 IN A 203.0.113.10
Confirm it is the authoritative value, not a resolver artifact
A public resolver's cached TTL can be lower than the real value simply because time has already ticked down since it last fetched the record. Ask the authoritative nameserver directly to see the true configured TTL.
dig +short NS example.com
dig @ns1.example-dns.com example.com A +noall +answer
# good confirmation: the authoritative answer shows the same low TTL,
# meaning it really is configured that way, not just a stale cache
Estimate the query load this TTL is causing
Query volume is roughly the number of unique resolvers hitting your domain divided by the TTL in seconds. A TTL of 60 seconds on a domain with a lot of unique resolver traffic can mean a meaningful, steady stream of authoritative queries around the clock.
# pull DNS analytics from your provider, for example Cloudflare:
curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/analytics/dashboard"
# rough math: estimated_qps = unique_resolvers_per_day / ttl_seconds / 86400
# example: 500000 unique resolvers/day at TTL=60 is roughly 0.096 qps per
# resolver-cache-cycle, but the real authoritative load scales with how
# many separate resolvers hold a cache entry, so a TTL drop from 3600 to
# 60 is still about a 60x multiplier on the query count either way
Rule out an active cutover before touching anything
A short TTL that is part of a migration in progress is doing its job. Trace the full resolution path to confirm there is no cutover currently underway that still needs the fast propagation.
dig +trace example.com
# good result if no migration is active: the chain resolves cleanly and
# nobody on the team can point to a reason the TTL still needs to be short
Raise the TTL to a safe steady-state value
Once you have confirmed there is no active migration, set the TTL back to something sane. An hour is the normal default for production traffic, shorter if you genuinely need fast failover, and longer for records that almost never change.
; before: left over from a migration months ago
example.com. 60 IN A 203.0.113.10
; after: normal production traffic, one hour
example.com. 3600 IN A 203.0.113.10
; or, if you need faster failover on this record specifically
example.com. 300 IN A 203.0.113.10
; records that rarely change, like NS or MX, can go up to a full day
example.com. 86400 IN NS ns1.example-dns.com.
Apply it through the Cloudflare API, or the host's UI
If you manage the zone through Cloudflare, patch the TTL directly. Otherwise, open the record at your DNS host or registrar and change the TTL field from 60 seconds to your chosen value, such as the 1 hour preset.
curl -s -X PATCH \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/dns_records/$RECORD_ID" \
-d '{"ttl": 3600}'
# note: use "ttl": 1 only intentionally, to mean "Auto" on a proxied
# record, never as a stand-in for a real short value
How to check it worked
Re-query after the change has had time to propagate through the old, short TTL window, then confirm from multiple angles that the new value stuck.
dig +noall +answer example.com A
# good result: TTL column now shows 3600 (or your chosen value)
# instead of 60
dig @ns1.example-dns.com example.com A +noall +answer
# confirms the authoritative source itself now serves the new TTL
dig @8.8.8.8 example.com A +noall +answer
dig @1.1.1.1 example.com A +noall +answer
# good result: both public resolvers agree on the new, longer TTL once
# their old cached copies expire
# then watch the Cloudflare Analytics API, or your authoritative query
# logs, over the next cache cycle for a drop in queries per second on
# this record, roughly proportional to the TTL increase
The full code
Here is a script that reads a record's current TTL, combines it with a known or estimated count of daily unique resolvers to estimate authoritative query load using a pure decision function, and, if the record is flagged as risky, raises the TTL through the Cloudflare API. It stays in dry run by default so it reports before it writes.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect a TTL that is too low for a record's real traffic and, on
repair, raise it back to a safe value through the Cloudflare API.
Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("ttl_too_low")
TTL_LADDER = (60, 120, 300, 900, 3600, 86400)
def assess_ttl_risk(ttl_seconds, daily_unique_resolvers, qps_risk_threshold=5.0, min_safe_ttl=300):
"""Pure decision function. No DNS I/O, no network calls.
ttl_seconds: the record's current TTL, in seconds, as read from a
live answer (dnspython) or the provider API.
daily_unique_resolvers: a known or estimated count of unique
resolvers/clients hitting the domain per day.
qps_risk_threshold: flag the record once estimated authoritative
queries per second crosses this value.
min_safe_ttl: flag the record if its TTL is below this floor,
regardless of estimated QPS (a 30 second TTL is a red flag
even on a quiet domain).
Returns {"risky": bool, "estimated_qps": float, "recommended_ttl": int}.
"""
safe_ttl = max(ttl_seconds, 1)
estimated_qps = daily_unique_resolvers / safe_ttl
risky = estimated_qps > qps_risk_threshold or ttl_seconds < min_safe_ttl
recommended_ttl = safe_ttl
for candidate in TTL_LADDER:
if daily_unique_resolvers / candidate <= qps_risk_threshold and candidate >= min_safe_ttl:
recommended_ttl = candidate
break
else:
recommended_ttl = TTL_LADDER[-1]
return {
"risky": risky,
"estimated_qps": estimated_qps,
"recommended_ttl": recommended_ttl,
}
def run():
# Imported lazily so the pure function above can be tested with no
# network libraries installed at all.
import dns.resolver
import requests
domain = os.environ["DNS_DOMAIN"]
zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
api_token = os.environ["CLOUDFLARE_API_TOKEN"]
daily_unique_resolvers = int(os.environ.get("DAILY_UNIQUE_RESOLVERS", "50000"))
dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
resolver = dns.resolver.Resolver()
answer = resolver.resolve(domain, "A")
current_ttl = answer.rrset.ttl
log.info("Live TTL for %s A record: %d seconds", domain, current_ttl)
result = assess_ttl_risk(current_ttl, daily_unique_resolvers)
log.info(
"Estimated QPS: %.4f, risky: %s, recommended TTL: %d",
result["estimated_qps"], result["risky"], result["recommended_ttl"],
)
if not result["risky"]:
log.info("No fix needed. TTL is within a safe range for this traffic level.")
return
if dry_run:
log.info(
"Dry run: would raise TTL for %s from %d to %d seconds",
domain, current_ttl, result["recommended_ttl"],
)
return
list_resp = requests.get(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
headers=headers,
params={"type": "A", "name": domain},
timeout=30,
)
list_resp.raise_for_status()
records = list_resp.json().get("result", [])
record_id = next((r["id"] for r in records), None)
if record_id is None:
log.warning("No existing A record id found to update at %s", domain)
return
patch_resp = requests.patch(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}",
headers=headers,
json={"ttl": result["recommended_ttl"]},
timeout=30,
)
patch_resp.raise_for_status()
log.info("Raised TTL for %s to %d seconds", domain, result["recommended_ttl"])
if __name__ == "__main__":
run()
/**
* Detect a TTL that is too low for a record's real traffic and, on
* repair, raise it back to a safe value through the Cloudflare API.
* Safe to run on a schedule. Stays in dry run until DRY_RUN=false.
*/
import { pathToFileURL } from "node:url";
const TTL_LADDER = [60, 120, 300, 900, 3600, 86400];
export function assessTtlRisk(ttlSeconds, dailyUniqueResolvers, qpsRiskThreshold = 5.0, minSafeTtl = 300) {
// Pure decision function. No DNS I/O, no network calls.
//
// ttlSeconds: the record's current TTL, in seconds, as read from a
// live answer (node:dns) or the provider API.
// dailyUniqueResolvers: a known or estimated count of unique
// resolvers/clients hitting the domain per day.
// qpsRiskThreshold: flag the record once estimated authoritative
// queries per second crosses this value.
// minSafeTtl: flag the record if its TTL is below this floor,
// regardless of estimated QPS.
//
// Returns { risky, estimatedQps, recommendedTtl }.
const safeTtl = Math.max(ttlSeconds, 1);
const estimatedQps = dailyUniqueResolvers / safeTtl;
const risky = estimatedQps > qpsRiskThreshold || ttlSeconds < minSafeTtl;
let recommendedTtl = TTL_LADDER[TTL_LADDER.length - 1];
for (const candidate of TTL_LADDER) {
if (dailyUniqueResolvers / candidate <= qpsRiskThreshold && candidate >= minSafeTtl) {
recommendedTtl = candidate;
break;
}
}
return { risky, estimatedQps, recommendedTtl };
}
export async function run() {
// Imported lazily so the pure function above can be tested with no
// network modules touched at all.
const dns = await import("node:dns");
const resolvePromises = dns.promises;
const domain = process.env.DNS_DOMAIN;
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
const dailyUniqueResolvers = Number(process.env.DAILY_UNIQUE_RESOLVERS || 50000);
const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };
const records = await resolvePromises.resolve4(domain, { ttl: true });
const currentTtl = records[0].ttl;
console.log(`Live TTL for ${domain} A record: ${currentTtl} seconds`);
const result = assessTtlRisk(currentTtl, dailyUniqueResolvers);
console.log(
`Estimated QPS: ${result.estimatedQps.toFixed(4)}, risky: ${result.risky}, recommended TTL: ${result.recommendedTtl}`
);
if (!result.risky) {
console.log("No fix needed. TTL is within a safe range for this traffic level.");
return;
}
if (dryRun) {
console.log(`Dry run: would raise TTL for ${domain} from ${currentTtl} to ${result.recommendedTtl} seconds`);
return;
}
const listRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?type=A&name=${encodeURIComponent(domain)}`,
{ headers }
);
if (!listRes.ok) throw new Error(`Cloudflare API list returned ${listRes.status}`);
const listBody = await listRes.json();
const record = (listBody.result || [])[0];
if (!record) {
console.warn(`No existing A record id found to update at ${domain}`);
return;
}
const patchRes = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${record.id}`,
{
method: "PATCH",
headers,
body: JSON.stringify({ ttl: result.recommendedTtl }),
}
);
if (!patchRes.ok) throw new Error(`Cloudflare API patch returned ${patchRes.status}`);
console.log(`Raised TTL for ${domain} to ${result.recommendedTtl} seconds`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The risk assessment is the part worth testing on its own, because it decides whether the script thinks a TTL is safe at all. It is a pure function of three plain numbers, so the tests need no network, no DNS library, and no credentials.
from ttl_too_low import assess_ttl_risk
def test_low_ttl_high_traffic_is_risky():
result = assess_ttl_risk(60, daily_unique_resolvers=500000)
assert result["risky"] is True
assert result["estimated_qps"] > 5.0
def test_normal_ttl_low_traffic_is_not_risky():
result = assess_ttl_risk(3600, daily_unique_resolvers=1000)
assert result["risky"] is False
def test_ttl_below_min_safe_is_risky_even_with_low_traffic():
result = assess_ttl_risk(30, daily_unique_resolvers=100)
assert result["risky"] is True
def test_recommended_ttl_is_from_the_ladder():
result = assess_ttl_risk(60, daily_unique_resolvers=500000)
assert result["recommended_ttl"] in (60, 120, 300, 900, 3600, 86400)
def test_recommended_ttl_brings_qps_under_threshold():
result = assess_ttl_risk(60, daily_unique_resolvers=100000, qps_risk_threshold=5.0)
assert 100000 / result["recommended_ttl"] <= 5.0
def test_zero_ttl_does_not_divide_by_zero():
result = assess_ttl_risk(0, daily_unique_resolvers=1000)
assert result["estimated_qps"] == 1000.0
assert result["risky"] is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { assessTtlRisk } from "./ttl-too-low.js";
test("low TTL with high traffic is risky", () => {
const result = assessTtlRisk(60, 500000);
assert.equal(result.risky, true);
assert.ok(result.estimatedQps > 5.0);
});
test("normal TTL with low traffic is not risky", () => {
const result = assessTtlRisk(3600, 1000);
assert.equal(result.risky, false);
});
test("TTL below min safe is risky even with low traffic", () => {
const result = assessTtlRisk(30, 100);
assert.equal(result.risky, true);
});
test("recommended TTL comes from the ladder", () => {
const result = assessTtlRisk(60, 500000);
assert.ok([60, 120, 300, 900, 3600, 86400].includes(result.recommendedTtl));
});
test("recommended TTL brings QPS under the threshold", () => {
const result = assessTtlRisk(60, 100000, 5.0);
assert.ok(100000 / result.recommendedTtl <= 5.0);
});
test("zero TTL does not divide by zero", () => {
const result = assessTtlRisk(0, 1000);
assert.equal(result.estimatedQps, 1000);
assert.equal(result.risky, true);
});
Case studies
The migration that ended but the TTL that did not
A team moved a high-traffic domain to a new load balancer and dropped the A record TTL from 3600 to 30 seconds the night before the cutover, exactly as planned. The cutover went smoothly. Nobody wrote "revert TTL" into the runbook, so the 30 second value stayed live for four months.
Authoritative query logs showed a steady, unexplained climb that the team first investigated as a possible DDoS. Reading the live TTL with dig found the leftover 30 second value immediately. Raising it back to 3600 seconds dropped authoritative query volume by roughly 95 percent within an hour.
A template TTL that was never meant for production
A new subdomain was created from a template zone file that had a 60 second TTL baked in from an old testing setup. The subdomain went into real production use and picked up meaningful traffic within a few weeks, still on the test TTL nobody had reviewed.
A scheduled script comparing TTL against estimated daily resolver counts flagged the subdomain automatically once traffic crossed the risk threshold. Raising the TTL to 900 seconds, since the team wanted moderately fast failover on that record, brought the estimated query rate back under the threshold.
Once the TTL matches how often a record actually needs to change, resolvers cache for a sensible length of time and authoritative query volume settles into a steady, predictable baseline instead of a needless multiple of it. Keep a short TTL only for the hours or days a migration is actually happening, and put a step in the runbook, or this script on a schedule, that raises it back the moment the cutover is confirmed.
FAQ
Why does a low TTL cause more load on my nameservers?
TTL tells resolvers how long they can serve an answer from cache before asking again. A lower TTL means every resolver's cached copy expires sooner, so they come back to your authoritative nameservers far more often. Query volume is roughly inversely proportional to TTL, so dropping from 3600 seconds to 60 seconds can multiply query load by about 60 times.
How low is too low for a TTL?
A TTL under about 120 seconds on a record that is not in an active migration window is a red flag, and anything forced below 60 seconds on a busy domain is worth checking closely. Short TTLs of 60 to 120 seconds are fine and expected during an active cutover, but they should be raised back to a steady-state value like 3600 seconds once the migration is over.
What TTL should I use once the migration is done?
For normal production traffic, 3600 seconds (one hour) is a sane steady-state value. Use 300 to 900 seconds if you need faster failover on a record that might change without notice. Records that almost never change, like NS or MX, can safely go as high as 86400 seconds (one day).
Related field notes
Citations
On the problem:
- RFC 1035: Domain Names, Implementation and Specification, TTL field definition. rfc-annotations.research.icann.org/rfc1035.html
- Cloudflare docs: Time to Live (TTL), how caching duration affects query volume. developers.cloudflare.com/dns/manage-dns-records/reference/ttl
- AWS docs: Best practices for Amazon Route 53 DNS, TTL guidance. docs.aws.amazon.com/Route53/latest/DeveloperGuide/best-practices-dns.html
On the solution:
- Cloudflare docs: Time to Live (TTL), setting and changing values. developers.cloudflare.com/dns/manage-dns-records/reference/ttl
- Cloudflare API reference: DNS Records, update DNS record (TTL patch). developers.cloudflare.com/api/resources/dns/subresources/records/methods/update
- AWS Networking & Content Delivery Blog: DNS best practices for Amazon Route 53. aws.amazon.com/blogs/networking-and-content-delivery/dns-best-practices-for-amazon-route-53
Stuck on a tricky one?
If you have a DNS, email deliverability, or certificate problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your nameserver load?
If this saved you a scramble chasing a phantom DDoS, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi