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.

dig and dnspython Python and Node.js Fixable through the DNS API
Wires in a server rack
Photo by imgix on Unsplash
The short answer

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.

TTL set to 60s for a migration Migration ends, TTL never reverted still 60s, months later cache empties every 60s Query load x60 resolvers re-ask often Slow answers or rate limiting
A short TTL left in place long after the migration ends keeps every resolver's cache emptying out on a short clock, and the authoritative nameserver pays for it in query volume.

Why it happens

The key insight

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.

Read live TTL dig @authoritative-ns Estimate query load traffic divided by TTL Check for active migration window Risk over threshold? yes no, leave it Raise TTL, verify re-check answer and QPS
Once the TTL matches the record's real traffic and change frequency, resolvers cache for longer and the authoritative nameservers see a proportionally smaller, steadier query load.

How to fix it

1

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.

Terminal
read-ttl (shell)
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
2

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.

Terminal
confirm-authoritative (shell)
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
3

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.

Terminal
estimate-load (shell)
# 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
4

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.

Terminal
trace (shell)
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
5

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.

DNS record
zone record
; 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.
6

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.

Terminal
apply-ttl (shell)
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.

Terminal
verify (shell)
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.

ttl_too_low.py
"""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()
ttl-too-low.js
/**
 * 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.

test_ttl_risk.py
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
ttl-too-low.test.js
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

Forgotten revert

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.

Copy-paste config

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.

What good looks like

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:

  1. RFC 1035: Domain Names, Implementation and Specification, TTL field definition. rfc-annotations.research.icann.org/rfc1035.html
  2. Cloudflare docs: Time to Live (TTL), how caching duration affects query volume. developers.cloudflare.com/dns/manage-dns-records/reference/ttl
  3. 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:

  1. Cloudflare docs: Time to Live (TTL), setting and changing values. developers.cloudflare.com/dns/manage-dns-records/reference/ttl
  2. Cloudflare API reference: DNS Records, update DNS record (TTL patch). developers.cloudflare.com/api/resources/dns/subresources/records/methods/update
  3. 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.

Contact me on LinkedIn

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

Back to all DNS and Domains field notes