Cloudflare Error 1016 Origin DNS Error: Fix in Workers
Your browser is showing Cloudflare's Error 1016 page — "Origin DNS error" — and the request died at the edge, not in your application. Error 1016 means Cloudflare could not resolve the IP address of the origin it was told to connect to: a DNS problem sitting in front of your code. On Cloudflare Workers it almost always means one of two things: a Worker fetch() subrequest hit a hostname the edge resolver cannot resolve, or the DNS record behind your route or custom domain is missing, stale, or dangling.
This one cost me a morning on a Worker that calls an internal API on a subdomain of the same zone. Locally, wrangler dev answered fine. At the edge, every request came back as a Cloudflare error page with status 530. I redeployed twice before I stopped blaming the code and looked at what Cloudflare's resolver could actually see — which is not the same DNS your laptop sees.
TL;DR
- Error 1016 = origin DNS failure. Cloudflare cannot resolve the origin server's IP address. The visitor-facing page says
Error 1016 Origin DNS error; the underlying HTTP status is Cloudflare's internal530. - Two distinct Worker causes: (1) a
fetch()subrequest to a hostname in a Partial (CNAME) setup zone that has no DNS record inside Cloudflare; (2) the origin record behind your own hostname is missing (no A record), points at a dead IP, or is a CNAME to a target that no longer resolves. - Subrequest failures split into two modes. A Cloudflare-routed destination that fails edge DNS comes back as a Response with status
530(1016) — inspectresponse.status, because atry/catcharoundfetch()alone misses it. A plain network or DNS failure (an unresolvable hostname that is not Cloudflare-routed, a refused connection) makesfetch()reject with aTypeError— that one belongs in thecatchpath. - Fix order: identify the failing hostname → prove whether it resolves (
dig/getent) → add or repair the DNS record in the Cloudflare dashboard → replay the request. No code change fixes a missing record. - Related reading: this is DNS-level, so it sits next to Error 1101 (unhandled exception) and Error 1102 (resource limits) as "the Worker runs, but the edge refuses" — and is a cousin of Cloudflare 524, where the origin answers too slowly instead of not resolving at all.
What Error 1016 actually means
Per the Cloudflare support documentation, Error 1016 indicates that Cloudflare cannot resolve the origin web server's IP address. The documented causes are: a missing A record for the origin IP; a CNAME record in Cloudflare DNS that points to an unresolvable external domain; unresolvable origin hostnames in a Load Balancer pool; a Spectrum app whose CNAME origin was never added to Cloudflare DNS; and — the Workers-specific one — a fetch() subrequest to a hostname in a target Partial (CNAME) setup zone that has no record in the Cloudflare zone.
Two codes travel together here. The browser-facing page reports Error 1016, but the actual HTTP status Cloudflare returns is 530 Origin DNS Error — 530 is Cloudflare's internal code for "the origin could not be resolved," and it never reaches your origin because no connection is ever attempted. That is why the fix is always in DNS configuration, never in application code.
Compare that with the other Worker error pages: Error 1101 means your Worker threw an unhandled JavaScript exception; Error 1102 means it exceeded CPU or memory. Both happen after the request reaches your isolate. 1016 happens before — Cloudflare could not even figure out where to connect.
Why it works locally but dies at the edge
The single most confusing symptom is that wrangler dev and curl from your laptop succeed while the same request fails in production. The reason is which resolver is doing the lookup:
- Your laptop resolves hostnames against your system resolver (your router, your VPN,
/etc/hosts, a private DNS server). A hostname that exists only in your internal DNS or only at your registrar resolves fine. - Cloudflare Workers resolve every
fetch()subrequest with Cloudflare's own DNS resolver. Your/etc/hosts, your VPN DNS, and your authoritative-only records are invisible to it. If a hostname is not resolvable from Cloudflare's resolver, the subrequest fails with status530 (1016)— even though the exact same URL works inwrangler devon your machine. - Visitors' browsers resolve your public hostname through public DNS, which Cloudflare itself serves for your zone. If the DNS record for your own domain is missing from Cloudflare (Partial setup) or points to a dead target, the visitor's request reaches Cloudflare and then dies at origin resolution.
This split is documented in Cloudflare's Workers known issues: in a Partial (CNAME) setup zone, every hostname a Worker needs to resolve must have a dedicated DNS entry in Cloudflare's DNS setup — otherwise the Fetch API call fails with status code 530 (1016). A local test can never reproduce that, because your machine and Cloudflare are consulting different DNS.
Diagnose: find the hostname Cloudflare cannot resolve
Before touching any record, identify the hostname that fails. The 1016 page does not name it in most cases, but the error body sometimes shows the stale origin (a community case after a Pages → Workers migration displayed the deleted *.pages.dev hostname on the page). When it does not, work backwards from your code.
For a Worker, log every subrequest's URL and status for the failing window. The minimal version: a route that probes each configured upstream and reports what came back. Deploy it, replay the failing request, and read the per-URL result in the JSON body:
// Diagnostic Worker: report what the edge resolver did with each subrequest.
// Deploy on a route, hit it, and read the per-URL result in the JSON body.
export default {
async fetch(request, env) {
const targets = (env.TARGETS ?? "https://api.example.com/health")
.split(",")
.map((s) => s.trim());
const out = [];
for (const url of targets) {
try {
const upstream = await fetch(url, {
headers: { "user-agent": "c2cz-diag/1.0" },
});
out.push({ url, status: upstream.status, ok: upstream.ok });
} catch (err) {
out.push({ url, thrown: err.name + ": " + err.message });
}
}
return Response.json(out, {
headers: {
"content-type": "application/json; charset=utf-8",
"x-content-type-options": "nosniff",
},
});
},
};
Status 530 in the JSON means the destination is Cloudflare-routed and the edge's own DNS layer could not resolve the origin (error code 1016). A thrown TypeError means a network-level failure — the hostname did not resolve, or nothing accepted the connection; for hostnames that are not Cloudflare-routed, this is exactly how a DNS failure surfaces. Status 200 means the target is fine and your 1016 is coming from somewhere else — usually the DNS record behind the route you are visiting, not a subrequest.
Next, prove whether the hostname resolves at all. dig is the canonical tool (package dnsutils on Debian/Ubuntu, bind-utils on RHEL); if it is not installed, getent ahosts does the same job with no extra package. I verified the getent form on a Linux host while writing this: a resolvable name prints its addresses with exit 0, an unresolvable .invalid name prints nothing with exit 2.
# Canonical check — dig +short prints NOTHING when the hostname does not
# resolve or the CNAME chain is broken. Judge by the empty output, not the
# exit code: dig exits 0 for NXDOMAIN because the query itself succeeded.
dig +short api.example.com
dig +short nonexistent-1016-test.invalid
# Same check without dig — getent exits 2 when the name does not resolve.
getent ahosts api.example.com
echo $?
getent ahosts nonexistent-1016-test.invalid
echo $?
Run the failing hostname through that check twice: once from your machine, once from a public resolver (a second machine, or an online lookup tool such as dnschecker.org, which the official 1016 doc points to). If it resolves publicly but the Worker still returns 530, the problem is almost certainly that the hostname is missing from Cloudflare's zone — Fix 3 below.
Fix 1 — missing or stale A record on a full-setup origin
The classic case, and the first one the docs list: your zone is on a full setup, and the hostname you are hitting has no A record in Cloudflare DNS, or the A record points at an IP that no longer serves the site. In the Cloudflare dashboard, go to DNS → Records and check the hostname the request targets:
| Symptom at the edge | What is usually wrong in DNS → Records |
|---|---|
| Error 1016 / status 530 | No A record for the hostname, or a CNAME whose target does not resolve |
| 521 or 522 (origin refused / timed out) | A record exists but the IP is stale — the origin moved or the port is closed |
Works in wrangler dev, 530 in production | Record exists at your registrar/authoritative DNS but not in the Cloudflare zone (see Fix 3) |
To repair: delete the stale record, create a new A record with the hostname and the current origin IP, and keep the proxy (orange cloud) enabled if you want Cloudflare in front of the origin. If the origin's IP changes often, a CNAME to a stable hostname — for example a load balancer or a *.workers.dev-style endpoint — fails less often than a hand-maintained A record, provided the CNAME target itself resolves.
⚠️ After editing, give DNS a moment to propagate and replay the request. 1016 is not a cache problem, so "Purge Everything" will not fix it; what fixes it is the record being present and correct.
Fix 2 — CNAME target that no longer resolves (the Pages → Workers trap)
A CNAME record in Cloudflare DNS is only as good as its target. When the target stops resolving — a deleted *.pages.dev project, a retired SaaS hostname, an expired domain — Cloudflare cannot resolve the origin and serves 1016. This is exactly the failure in a Cloudflare Community case where a site switched from Pages to Workers: the author deleted the old DNS record and Page Rules, but 1016 persisted because the routing configuration still referenced the old *.pages.dev hostname, which no longer existed.
If you just migrated a hostname to a Worker, remove the ghosts in this order:
- Delete any DNS CNAME records that still point at the old platform hostname (for example
something.pages.dev). - Delete the old custom domain/hostname registration on the old platform project — deleting the DNS record alone is not enough if the old project still claims the hostname.
- Add the custom domain on the Worker instead: Workers & Pages → your Worker → Settings → Domains & Routes → Add Custom Domain, then enter the hostname. Cloudflare creates the routing and the certificate for it.
- Check for leftover Page Rules or Redirect Rules that forward the hostname to the dead target, and delete them.
Verify the CNAME chain from the outside: dig +short your-hostname should end in a live address, and dig +short old-target.pages.dev should not come back empty.
Fix 3 — Worker fetch() against a Partial (CNAME) setup zone
This is the pure Worker case and the one most people never suspect, because everything looks fine in code and locally. Your zone is on a Partial (CNAME) setup: DNS authority stays at your registrar or another DNS provider, and only specific hostnames are CNAME'd into Cloudflare. Your Worker fetch()es an internal hostname such as api.internal.example.com — a hostname that exists at your authoritative DNS but has no record in the Cloudflare zone.
Cloudflare's own documentation is explicit (Workers known issues — Fetch API in CNAME setup): Worker subrequests use the Cloudflare DNS resolver, and in a partial setup every hostname a Worker must resolve needs a dedicated DNS entry in Cloudflare's DNS. With the record present only at the authoritative DNS, the same setup that resolves sub1.example.com fails on sub2.example.com with status 530 (1016).
Fix A — add the record to the Cloudflare zone. In DNS → Records → Add record, create an A record (or CNAME, if the target is a hostname) for the exact subdomain the Worker fetches. The record does not need to be proxied for the Worker to resolve it — what matters is that it exists in Cloudflare's DNS so the edge resolver can find it. Once it is there, the subrequest resolves and the 530 disappears.
Fix B — never let Cloudflare's raw error page leak to your clients. Treat 530 defensively: a subrequest whose hostname disappears later (or a typo in an environment variable) must surface as a clean 503 with a JSON body, not as Cloudflare's HTML error page. Two failure modes matter. For a Cloudflare-routed destination, the edge DNS failure comes back as a Response with status 530 — that is the documented shape in the known-issues page and the Community discussion of fetch error handling — so a try/catch around fetch() alone misses it and you must inspect response.status. For hostnames that are not Cloudflare-routed, the same DNS failure makes fetch() reject with a TypeError, which lands in the catch path. The helper below handles both modes and converts them into clean JSON errors.
// Production subrequest helper. Cloudflare resolves subrequest DNS at the
// edge. Two distinct failure modes:
// 1) fetch() REJECTS with a TypeError for network-level failures — the
// hostname does not resolve (non-Cloudflare-routed), the connection is
// refused, or the request aborts.
// 2) fetch() RESOLVES with Cloudflare's own 530 response (error code 1016)
// when the destination is Cloudflare-routed and the edge cannot resolve
// the origin — e.g. the Partial (CNAME) setup zone case in the Workers
// known issues. A 530 is a DNS configuration problem: fix the record;
// retrying will not help.
const UPSTREAM = "https://api.internal.example.com/health";
async function fetchUpstream() {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
return await fetch(UPSTREAM, {
headers: { "user-agent": "c2cz-worker/1.0" },
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
export default {
async fetch(request, env) {
try {
let upstream = await fetchUpstream();
// Cloudflare edge DNS failure (1016). No retry: the record is missing
// or dangling; return a clean 503 until DNS is fixed.
if (upstream.status === 530) {
return json({ error: "upstream_dns_unavailable" }, 503);
}
// Transient upstream 5xx (including an upstream's own 502): one retry
// with backoff before giving up.
if (upstream.status >= 500 && upstream.status < 600) {
await new Promise((resolve) => setTimeout(resolve, 250));
upstream = await fetchUpstream();
}
if (!upstream.ok) {
return json({ error: "upstream_error", status: upstream.status }, 502);
}
return new Response(upstream.body, {
status: 200,
headers: {
"content-type":
upstream.headers.get("content-type") ?? "application/octet-stream",
"x-content-type-options": "nosniff",
"x-frame-options": "DENY",
},
});
} catch (err) {
// Network-level failure: DNS did not resolve, connection refused,
// or the request aborted. AbortError = our own 5s timeout.
if (err.name === "AbortError") {
return json({ error: "upstream_timeout" }, 504);
}
return json({ error: "upstream_unreachable" }, 503);
}
},
};
function json(body, status) {
return Response.json(body, {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"x-content-type-options": "nosniff",
"x-frame-options": "DENY",
},
});
}
💡 While you are in there: Worker subrequests cannot fetch a bare IP address — Cloudflare's Fetch API takes URLs, and the hostname must resolve through the edge resolver. If your origin is a bare IP, add an A record for it in your zone (for example server.example.com → 192.0.2.1) and fetch https://server.example.com, not http://192.0.2.1 — this is also in the Workers known issues.
Fix 4 — custom hostnames on Cloudflare for SaaS
If your Worker or Pages project serves customer domains through Cloudflare for SaaS, 1016 has a second meaning: Cloudflare returns it when a custom hostname cannot be routed or proxied. Documented causes: custom hostname ownership validation is incomplete, the fallback origin is not set, a wildcard custom hostname conflicts with a standalone zone, or the hostname has no DNS record in the SaaS target zone.
Check certificate validation status with the Custom Hostnames API — the 1016 doc's own recipe. The telling error is "verification_errors": ["custom hostname does not CNAME to this zone."], which clears once the certificate status is active:
# List custom hostnames and their SSL validation state (Cloudflare API v4).
# The list is paginated (per_page default 20): loop pages until page ==
# total_pages so no hostname is silently skipped. Abort loudly when the API
# envelope reports an error instead of printing "zero hostnames".
# Official docs: cloudflare-for-platforms -> start -> common-api-calls
PAGE=1
TOTAL_PAGES=1
while [ "$PAGE" -le "$TOTAL_PAGES" ]; do
curl -s --get "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/custom_hostnames" \
--data-urlencode "per_page=50" \
--data-urlencode "page=$PAGE" \
-H "Authorization: Bearer $CF_API_TOKEN" \
> /tmp/ch_page.json || { echo "curl failed (network or token)"; exit 1; }
jq -e '.success' /tmp/ch_page.json >/dev/null || {
jq -r '.errors[]?.message' /tmp/ch_page.json
echo "Cloudflare API error - check ZONE_ID and CF_API_TOKEN"; exit 1
}
jq -r '.result[] | [.hostname, .ssl.status, ((.ssl.validation_errors // []) | map(.message) | join("; "))] | @tsv' /tmp/ch_page.json
TOTAL_PAGES=$(jq -r '.result_info.total_pages' /tmp/ch_page.json)
PAGE=$((PAGE + 1))
done
Then, in order: complete ownership validation (the CNAME the dashboard gives you must actually exist and point at the SaaS zone), confirm the fallback origin has a DNS record and is set, and replace any wildcard custom hostname with specific hostnames if the domain also exists as a standalone zone in Cloudflare — standalone-zone hostname priority wins over a wildcard, so a wildcard + standalone zone pair routes to the standalone zone and 1016s when that zone has no record.
Verify the fix
After each fix, replay the exact request that failed and confirm you no longer get a 530. From a terminal, the status-code check (the pattern I used on every URL in this article):
curl -s -o /dev/null -w "%{http_code}\n" https://your-domain.example/some/path
# expect your application's status (200/301/404...), never 530
For the Worker subrequest case, re-run the diagnostic Worker from the diagnose section: the target that returned "status": 530 should now return "status": 200. Confirm the DNS fix from the outside with dig +short on the previously failing hostname — an empty answer before the fix and an address after it is the whole story in one command.
Error 1016 is one of the few Cloudflare failures that no amount of code hardening fixes, because the request never reaches your code. Identify the hostname, prove what the edge resolver sees, and repair the record. When the diagnosis is right, the fix is a two-minute dashboard change — and the Worker code change you were about to ship stays in the branch where it belongs.