This website uses cookies

Read our Privacy policy and Terms of use for more information.

What it actually is

Route 53 is three products sold under one name. It is an authoritative DNS service: you put records in a hosted zone, and AWS name servers answer queries about them. It is a health-checking service: a fleet of checkers around the world probes endpoints you name and feeds the result back into DNS answers. And it is a domain registrar, which is the least interesting of the three and the one this article ignores.

The mental model the docs bury: Route 53 does not route traffic. It chooses which answer to hand out when someone asks a question, and then it is out of the picture. The client caches that answer for the record's TTL. Every "failover" you will ever build with Route 53 is therefore bounded by two clocks you control (how fast the health check notices, and the TTL) and one clock you do not (how well downstream resolvers and clients respect the TTL).

The model

A hosted zone is a container for the records of a domain and its subdomains. A public hosted zone answers the internet. A private hosted zone answers only the VPCs you associate with it. AWS assigns four name servers per zone (a random selection by default, or a reusable delegation set if you create one through the API), and you must point your registrar at them for the zone to matter.

Records carry a name, a type, a TTL and a value. Alias records are the Route 53 extension: they point at certain AWS resources such as CloudFront distributions and S3 website endpoints, resolve to the target's current addresses, and can live at the zone apex where a CNAME cannot. Queries to alias records that map to supported AWS services are not billed.

The part worth your attention is the routing policy, which is the rule used to pick among records that share a name and type:

Policy

What it does

Simple

One resource, one answer

Weighted

Proportions you specify

Latency

The Region with the lowest measured latency to the user

Failover

Active-passive: primary until it is unhealthy, then secondary

Geolocation

By the user's location

Geoproximity

By the distance between user and resource, with an optional bias

IP-based

By the client's source CIDR

Multivalue answer

Up to eight healthy records chosen at random

Two details from the docs matter more than the table. Latency routing "is based entirely on traffic between users and AWS data centers", so if your resource is not in an AWS Region, the real latency can differ significantly from what Route 53 believes. And the measurements change over time, so a user routed to Oregon this week might go to Singapore next week. Latency records pick the lowest measured Region; they do not guarantee the fastest path for a given user on a given day.

Health checks are where the engine lives. You can check an endpoint over HTTP, HTTPS or TCP, optionally with string matching against the first 5,120 bytes of the body. Each checker declares an endpoint healthy or not based on consecutive results (the failure threshold). Then Route 53 aggregates across checkers, and here is the rule people never learn: if more than 18% of health checkers report an endpoint healthy, Route 53 considers it healthy. At 18% or fewer, it is unhealthy. The docs say the value was chosen so that an endpoint is not marked down just because some locations are isolated from it, and that the number might change in a future release. It is deliberately a very lenient definition of "up".

HTTPS health checks do not validate certificates, so an expired certificate will not fail the check. A TCP check must connect within ten seconds; an HTTP check must connect within four seconds and get a 2xx or 3xx within two more.

When to use it, when not to

You want

Use

Why

Authoritative DNS for your domain, health-aware answers

Route 53

This is the job

Routing by DNS answer across Regions

Route 53 latency or failover

Cheap, no data-path cost

Failover that does not wait on resolver caches

A service that fronts traffic (CloudFront, Global Accelerator, a load balancer)

DNS answers are cached; a front door is not

Cross-Region failover with a control-plane safety net

Route 53 plus Application Recovery Controller

ARC adds readiness checks and routing controls

Private name resolution inside VPCs

Private hosted zone

Visible only to associated VPCs

The honest limit of DNS failover is that you are asking the internet to forget something. With a 60 second TTL, well-behaved resolvers refresh within about a minute after the answer changes, but you will always have a tail of clients that cached longer. If a minute of tail is unacceptable, you want something in the data path, not just in the answer.

What it costs

From the Route 53 pricing page at time of writing:

Dimension

Price

Hosted zone

$0.50 per month for the first 25, $0.10 per month for each additional

Standard queries

$0.40 per million for the first billion, $0.20 per million after

Latency-based queries

$0.60 per million for the first billion, $0.30 after

Geolocation and geoproximity queries

$0.70 per million for the first billion, $0.35 after

Health check, AWS endpoint

$0.50 per month

Health check, non-AWS endpoint

$0.75 per month

Optional health check features (HTTPS, string matching, fast interval, latency measurement)

$1.00 per feature per month on AWS endpoints, $2.00 on non-AWS

Traffic Flow policy record

$50.00 per month

The line that surprises people is the optional features. HTTPS counts as one. A basic health check on a non-AWS endpoint is $0.75; add HTTPS and it is $2.75; add string matching and fast interval and it is $6.75, per check, per month. Multiply by the number of endpoints and by the number of checks you create "just in case" and the health check line can exceed the hosted zone and query lines combined. Also note that latency-based queries cost 50% more than standard ones, so a latency policy on a high-traffic name is a real line item.

There is no free tier for Route 53 on that page. Following along below costs well under a dollar if you finish and clean up the same day. I could not find a statement of how partial months are prorated for health checks or whether a hosted zone deleted quickly is billed for a full month, so assume the worst case of about $6 per month (one zone plus two HTTPS health checks on non-AWS endpoints) and delete everything when you are done.

The limits that bite

From the Route 53 quotas page:

Limit

Value

Hosted zones per account

500 initially, can be raised

Records per hosted zone

10,000 (raising it carries an additional charge)

Active health checks per account

200

Records with the same name and type for latency, geolocation, multivalue and weighted

100

Geoproximity records with the same name and type

30

VPCs associated with one private hosted zone

300 (AWS suggests Route 53 Profiles beyond that)

Traffic policies per account

50

ResourceRecord elements in one change request

1,000; UPSERT counts each twice

Characters in all Value elements in one request

32,000; UPSERT counts each twice

The API has two separate throttles: request rate and change throughput. A throttled call returns HTTP 400 with the code Throttling and the message Rate exceeded, which is a confusing status for a rate limit. Bulk record changes through UPSERT consume the 1,000-element budget at double rate, so a "batch of 600 upserts" fails validation and you will not understand why until you remember that rule.

Three behaviors cost real incidents. First, Route 53 has a mode of last resort: when every record in a set is unhealthy, the algorithm reverts to treating all of them as healthy, rather than returning nothing. That is a good default, and it also means a misconfigured health check cannot take your whole name offline, but it also means "everything is down" looks like a normal answer from DNS. Second, you cannot change a health check's request interval after you create it; you recreate it. Third, the control plane for public hosted zones lives in one Region. Accelerated recovery, which you enable per public hosted zone before you need it, keeps a copy in US West (Oregon) so you can resume DNS changes within about 60 minutes of AWS detecting that US East (N. Virginia) is impaired. DNS answering itself keeps working during such an event; what you lose is the ability to change records. It does not cover private hosted zones, and during failover you cannot create or delete hosted zones or toggle DNSSEC signing.

Build it: a latency-based failover across two Regions

You will deploy the same tiny Lambda function in us-east-1 and eu-west-1 behind function URLs, create a public hosted zone, attach one HTTPS health check per endpoint, publish two latency CNAME records, then break us-east-1 and watch Route 53 stop returning it. Lambda itself is covered in the Lambda article of this series.

A caveat in the interest of honesty: the code below follows the current AWS documentation and API references, but I did not execute it against a live account for this issue. Read the output of each step, and treat the timings as estimates from the documented interval and threshold, not as measurements.

Prerequisites

  • An AWS account and credentials in a profile with the permissions below, Python 3.10+, and boto3.

  • A DNS name you are willing to put in a hosted zone, set as DOMAIN. You do not need to own it or delegate it for this lab, because the verification uses the TestDNSAnswer API, which queries Route 53 directly. Delegation only matters once real users should find the zone.

  • IAM actions: route53:CreateHostedZone, route53:ChangeResourceRecordSets, route53:ListResourceRecordSets, route53:CreateHealthCheck, route53:GetHealthCheckStatus, route53:DeleteHealthCheck, route53:TestDNSAnswer, route53:DeleteHostedZone, route53:GetChange, plus lambda:CreateFunction, lambda:CreateFunctionUrlConfig, lambda:AddPermission, lambda:PutFunctionConcurrency, lambda:DeleteFunctionConcurrency, lambda:DeleteFunctionUrlConfig, lambda:DeleteFunction, iam:CreateRole, iam:AttachRolePolicy, iam:PassRole, iam:DetachRolePolicy, iam:DeleteRole.

Step 1: two functions with public URLs

A function URL with auth type NONE needs a resource-based policy with two statements, one for lambda:InvokeFunctionUrl and one for lambda:InvokeFunction, and AWS recommends the lambda:InvokedViaFunctionUrl condition on the second. Without them callers get 403 even though the auth type is NONE. The Route 53 checkers are anonymous callers, so this is the right setup for the lab, and it is public by design, which is why cleanup matters.

import io, json, time, zipfile, boto3

REGIONS = ["us-east-1", "eu-west-1"]
ROLE = "r53-lab-role"
FN = "r53-lab-fn"

iam = boto3.client("iam")
trust = {"Version": "2012-10-17", "Statement": [{
    "Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"},
    "Action": "sts:AssumeRole"}]}
role = iam.create_role(RoleName=ROLE, AssumeRolePolicyDocument=json.dumps(trust))
iam.attach_role_policy(RoleName=ROLE, PolicyArn=
    "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole")
time.sleep(10)  # IAM propagation

CODE = '''
import os
def handler(event, context):
    return {"statusCode": 200, "headers": {"content-type": "text/plain"},
            "body": "hello from " + os.environ["AWS_REGION"]}
'''
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
    z.writestr("app.py", CODE)

hosts = {}
for r in REGIONS:
    lam = boto3.client("lambda", region_name=r)
    lam.create_function(FunctionName=FN, Runtime="python3.13",
        Role=role["Role"]["Arn"], Handler="app.handler",
        Code={"ZipFile": buf.getvalue()})
    lam.get_waiter("function_active_v2").wait(FunctionName=FN)
    url = lam.create_function_url_config(FunctionName=FN, AuthType="NONE")
    lam.add_permission(FunctionName=FN, StatementId="UrlPolicyInvokeURL",
        Action="lambda:InvokeFunctionUrl", Principal="*",
        FunctionUrlAuthType="NONE")
    lam.add_permission(FunctionName=FN, StatementId="UrlPolicyInvokeFunction",
        Action="lambda:InvokeFunction", Principal="*",
        InvokedViaFunctionUrl=True)
    hosts[r] = url["FunctionUrl"].replace("https://", "").rstrip("/")
print(hosts)

Check the runtime string against the currently supported list before you run this; runtimes are retired on a schedule. Verify each endpoint answers: curl https://<host>/ should print "hello from us-east-1" and "hello from eu-west-1" respectively.

Step 2: hosted zone and health checks

The health check needs the bare hostname (no scheme, no path), port 443, type HTTPS. A request interval of 30 seconds with a failure threshold of 3 means a single checker needs roughly 90 seconds of consecutive failures to flip, and the aggregate then follows the 18% rule. Fast (10 second) intervals cost extra and cannot be changed after creation.

import uuid
r53 = boto3.client("route53")
DOMAIN = "lab.example.net"   # set to a name you control

zone = r53.create_hosted_zone(Name=DOMAIN,
    CallerReference=str(uuid.uuid4()))["HostedZone"]["Id"].split("/")[-1]

hc = {}
for r, host in hosts.items():
    hc[r] = r53.create_health_check(
        CallerReference=str(uuid.uuid4()),
        HealthCheckConfig={"Type": "HTTPS", "FullyQualifiedDomainName": host,
            "Port": 443, "ResourcePath": "/",
            "RequestInterval": 30, "FailureThreshold": 3},
    )["HealthCheck"]["Id"]
    r53.change_tags_for_resource(ResourceType="healthcheck", ResourceId=hc[r],
        AddTags=[{"Key": "Name", "Value": f"r53-lab-{r}"}])

Step 3: the latency records

Each Region gets one CNAME on the same name, with a SetIdentifier, a Region, and its own HealthCheckId. A TTL of 60 seconds is a deliberate trade: shorter means faster failover for compliant resolvers and more billed queries.

changes = []
for r, host in hosts.items():
    changes.append({"Action": "UPSERT", "ResourceRecordSet": {
        "Name": f"app.{DOMAIN}", "Type": "CNAME", "TTL": 60,
        "SetIdentifier": r, "Region": r, "HealthCheckId": hc[r],
        "ResourceRecords": [{"Value": host}]}})
cid = r53.change_resource_record_sets(HostedZoneId=zone,
    ChangeBatch={"Changes": changes})["ChangeInfo"]["Id"]
r53.get_waiter("resource_record_sets_changed").wait(Id=cid)

Step 4: verify the healthy state

TestDNSAnswer simulates a query against a public hosted zone and returns what Route 53 would answer. With no resolver IP it uses a resolver in us-east-1, so the expected answer is the us-east-1 host. It does not support private hosted zones.

def ask():
    a = r53.test_dns_answer(HostedZoneId=zone, RecordName=f"app.{DOMAIN}",
                            RecordType="CNAME")
    return a["ResponseCode"], a["RecordData"]

def healthy(r):
    obs = r53.get_health_check_status(HealthCheckId=hc[r])["HealthCheckObservations"]
    return sum(1 for o in obs if o["StatusReport"]["Status"].startswith("Success")), len(obs)

print(ask()); print({r: healthy(r) for r in hosts})

Expect NOERROR and the us-east-1 hostname. Health checkers need a minute or two to report; a brand new health check is treated as healthy until there is enough data. To see the other direction, pass ResolverIP and Edns0ClientSubnetIP (an address you know is in Europe) to the same call; the client subnet only takes effect if that resolver supports EDNS0, and I have not verified which public resolver the simulator honors, so treat that as an experiment.

Step 5: kill us-east-1

Setting reserved concurrency to zero deactivates a function URL: every request is throttled and returns HTTP 429, which fails the checker's 2xx or 3xx requirement.

lam = boto3.client("lambda", region_name="us-east-1")
lam.put_function_concurrency(FunctionName=FN, ReservedConcurrentExecutions=0)

t0 = time.time()
while time.time() - t0 < 600:
    code, data = ask()
    ok, n = healthy("us-east-1")
    print(f"{int(time.time()-t0):>4}s  {data}  checkers_ok={ok}/{n}")
    if any("eu-west-1" in d or hosts["eu-west-1"] in d for d in data):
        break
    time.sleep(15)

On the documented numbers the flip should arrive after roughly three consecutive failed 30-second probes from enough checkers to push the healthy share to 18% or below, so expect it in the range of one and a half to a few minutes. When it flips, the answer changes to the eu-west-1 host. Because TTL is 60 seconds, real clients converge about a minute after that, give or take the stubborn tail.

Now the mode of last resort. Throttle eu-west-1 too, wait for both checks to go unhealthy, and call ask() again. You will still get an answer, not an error, because when all records are unhealthy Route 53 treats all of them as healthy. That is the behavior to remember at 3 a.m.: a DNS answer is not evidence that anything is up.

Restore with lam.delete_function_concurrency(FunctionName=FN) in each Region.

When it breaks

  • Health check never turns healthy. The endpoint must answer 2xx or 3xx within the timeouts from checkers around the world, not from your laptop. Firewalls and security groups that only allow your own IP will fail the check; allowlist the published Route 53 health checker ranges.

  • Function URL returns 403. You added one permission statement, not two, or the principal is not "*". Add both.

  • ChangeResourceRecordSets returns a validation error on a big batch. Remember UPSERT counts each ResourceRecord twice against the 1,000 limit.

  • The answer is right but clients keep hitting the dead side. TTL and resolver behavior. Lower the TTL before an event, not during it, and remember a previously cached answer lives for its old TTL.

  • Healthy in the console, unreachable for users. The 18% rule says the endpoint was healthy from enough locations. Add string matching on a response that proves the dependency chain works.

  • Latency routes to the "wrong" Region. Measurements are AWS-network-based and change over time. Check where the user's resolver actually sits and whether it forwards EDNS0 client subnet.

  • Throttling, HTTP 400, Rate exceeded. The API rate limit or change throughput. Back off and batch.

Cleanup

Order matters: records first, then health checks, then the zone, then the Lambda resources. A hosted zone cannot be deleted while it still holds anything other than its default NS and SOA records.

r53.change_resource_record_sets(HostedZoneId=zone, ChangeBatch={"Changes": [
    {"Action": "DELETE", "ResourceRecordSet": rr}
    for rr in r53.list_resource_record_sets(HostedZoneId=zone)["ResourceRecordSets"]
    if rr["Type"] == "CNAME"]})
for r in hosts:
    r53.delete_health_check(HealthCheckId=hc[r])
r53.delete_hosted_zone(Id=zone)

for r in REGIONS:
    lam = boto3.client("lambda", region_name=r)
    lam.delete_function_url_config(FunctionName=FN)
    lam.delete_function(FunctionName=FN)
iam.detach_role_policy(RoleName=ROLE, PolicyArn=
    "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole")
iam.delete_role(RoleName=ROLE)

Deleting a function removes its resource-based policy with it; the docs warn that deleting only the URL does not remove a NONE policy, which is why the function itself is deleted here. Confirm in the Route 53 console that no hosted zones or health checks remain, since those are the two billed items that outlive a forgotten script.

Sources