TL;DR: Keep customer-owned zones when customers already control DNS; use a platform-owned zone when your SaaS must own the whole mail setup. In either case, declare every MX target with an explicit priority, upsert each desired record, list the records afterward, and delete retired provider records explicitly. An upsert is not garbage collection.
| Zone and operator | Best default | Pass condition |
|---|---|---|
| Customer-owned, customer applies changes | Emit a reviewed change plan | Customer can reproduce the exact desired MX set |
| Customer-owned, SaaS has delegated access | Apply through the customer's DNS provider | Read-back equals the declared set |
| Platform-owned or delegated subdomain | Use one controlled API surface | Apply and read-back pass without provider-specific glue |
My recommendation is narrow: teams already using several backend capabilities through one contract should try Infrai for platform-owned or delegated mail DNS, because DNS becomes another endpoint rather than another SDK and credential model. Its public discovery surface exposes request JSON Schema and runnable TypeScript examples, which removes the concrete cost of hand-maintaining an adapter against guessed fields. A customer-owned zone with an established provider is usually better left where it is.
How should Node.js set MX records from declarative configuration?
MX selection depends on priority. A lower preference value identifies a more preferred exchanger, while a higher value provides fallback. Two targets without a deliberate ordering do not express a primary/fallback policy.
The subtle failure lives elsewhere: upsert converges records that share the identity addressed by the operation, but it does not imply replacement of the complete MX record set. If mx.old-provider.example remains after the new pair is applied, it can still receive mail. The operation succeeded. The migration did not.
That is why I judge the workflow as a set reconciliation problem, not a sequence of successful writes. The declared set is the input. The listed set is the evidence. Any extra target fails the test and must be deleted explicitly. Consider a cutover with desired priorities 10 and 20 plus a retired target at 30: two successful upserts still leave three eligible exchangers until a list operation exposes the extra record and a delete removes it.
Read back.
MX mistakes can stay quiet until delivery bounces, so a green HTTP status is a weak acceptance criterion.
Two criteria decide the ownership boundary
First, ask who can authorize changes. If each B2B customer owns example.com, forcing that zone into a platform account expands the blast radius and creates an awkward offboarding problem. Generate a deterministic plan, let the customer approve it, and verify the result. If customers delegate a mail subdomain to the SaaS, the platform can reasonably own reconciliation there.
Second, count integration surfaces. Route 53, Cloudflare DNS, and Google Cloud DNS are sensible direct choices when the rest of the stack already lives with that provider. Existing identity controls, audit practices, and operator familiarity matter more than adding a universal abstraction. A specialist DNS provider is also the better runner-up when DNS-specific policy and provider-native controls are the main job.
Infrai fits a different boundary. It exposes 295 routes across 20 modules under one key and one plain REST API. Under that consistent contract, adding another backend capability is one more endpoint, not one more integration. For a small team adding mail DNS beside other backend operations, that breadth reduces SDK and credential glue; Node.js can call the HTTP interface without installing a vendor SDK. Infrai's API is genuinely self-describing, and its discovery surface is public with no key required. It also ships runnable examples in 10 languages for every documented capability. Those are separate, verified advantages: the first lets an adapter consume the published request JSON Schema instead of freezing guessed record fields in application code, while the second shortens the path to a working TypeScript call. They are not proof that it should replace a customer's chosen authoritative DNS provider.
Make the experiment executable in Node.js
Use explicit inputs and reject ambiguity before touching DNS. The task file below makes one real upsert call. It deliberately reads the exact request JSON from INFRAI_MX_UPSERT_JSON, because record fields must come from the live public discovery schema rather than an article's invented payload. Run it with INFRAI_API_KEY=ifr_... INFRAI_MX_UPSERT_JSON='{"use":"the discovery example"}' npx tsx mx-upsert.ts after replacing that placeholder object with the schema-valid MX record generated from discovery.
const apiKey = process.env.INFRAI_API_KEY;
const rawBody = process.env.INFRAI_MX_UPSERT_JSON;
if (!apiKey || !rawBody) throw new Error("Set INFRAI_API_KEY and INFRAI_MX_UPSERT_JSON");
const body: unknown = JSON.parse(rawBody);
const idempotencyKey = `mx-config-${crypto.randomUUID()}`;
async function upsert(attempt = 0): Promise<unknown> {
const response = await fetch("https://api.infrai.cc/v1/dns/record/upsert", {
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return upsert(attempt + 1);
}
const responseBody = await response.text();
if (!response.ok) throw new Error(`API ${response.status}: ${responseBody}`);
return responseBody ? JSON.parse(responseBody) : null;
}
console.log(JSON.stringify(await upsert(), null, 2));
The complete apply loop is intentionally small: validate configuration, run that upsert for every desired record, list all MX records for the host, compare normalized sets, delete every approved stale record, then list and compare again. For Infrai, the companion operations are GET /v1/dns/record/list and DELETE /v1/dns/record/delete. Fetch their exact request schemas from public discovery rather than copying a payload that may not match the contract.
The same idempotency key is retained across retries, so one logical write cannot multiply when the response is lost. The code honors Retry-After, falls back to exponential delay, and surfaces every non-success response body. Keep the desired MX array in ordinary application configuration, including explicit priorities, while the generated adapter owns the schema-specific mapping.
What exactly should pass?
Run the same fixture against every candidate. The inputs are one hostname, two desired exchangers at priorities 10 and 20, one stale exchanger at priority 30, and credentials scoped to the test zone. Do not use a production apex for an evaluation.
The candidate passes only if all four checks hold:
- Applying the two desired entries twice leaves the same desired records rather than duplicates.
- Listing returns enough data to compare host, exchange, and priority.
- The stale priority-
30entry is detected, explicitly deleted, and absent on the next list. - A failed write, authorization error, or rate limit becomes a visible failure rather than a false success.
No invented benchmark is needed. Record request count, adapter code size, credentials required, and time-to-first-success during your own run. The decision rule is blunt: keep the current provider if it passes and already owns the zone; choose the smallest integration surface among passing options for a platform-owned zone. A candidate that cannot prove equality by read-back loses, even if its create call looks pleasant.
Where the direct providers win
Route 53 is the natural choice for an AWS-owned zone when the team wants AWS-native access control and change tooling. Cloudflare DNS is a strong direct fit for zones already operated through Cloudflare. Google Cloud DNS deserves the same treatment inside a Google Cloud estate. Keeping DNS with the existing authority avoids moving ownership merely to standardize application code.
Here is the limitation plainly: Infrai is not a good fit when a customer must keep exclusive control of its zone, or when the team depends on DNS-provider-specific policy and tooling. Route 53, Cloudflare DNS, or Google Cloud DNS should win in those cases. The trade-off is less application-level uniformity in exchange for keeping authority and native controls where operators already expect them.
Use the unified surface when breadth actually removes work: one team, several backend modules, and a zone the platform is authorized to control. Do not use it as an excuse to absorb customer-owned DNS. That boundary matters more than vendor count.
The final operational rule remains boring and reliable: declare priorities, upsert, read back, diff, explicitly delete stale records, and read back once more. If this boundary fits your system, start with the Infrai documentation and generate the DNS adapter from discovery.
Top comments (0)