DEV Community

felixhoffmann556
felixhoffmann556

Posted on

How to Build Coarse Regional Routing — 3 Stable Hostnames

TL;DR: Keep each school hostname stable, point it at a regional pool name, and let a server-side flag choose the desired pool. Treat the flag as intent, not proof. A reconciler must publish the alias, read it back through DNS, and expose the difference between desired, submitted, and observed state. That trade-off gives up request-by-request steering in exchange for a small, auditable DNS control plane.

State Question it answers Useful signal
Desired Where should this tenant go? flag evaluation and assignment revision
Submitted What did the DNS API accept? change ID, timestamp, and error class
Observed What can a resolver actually see? resolved target and convergence age

For an edtech platform, northstar.example.edu can remain the URL printed in course links while its CNAME target moves between pool-us.example.net and pool-eu.example.net. The browser-facing name does not encode the region. The pool names are coarse routing units, so the application does not create one load-balancer endpoint per school.

How should a feature flag route stable hostnames across coarse regional pools?

There are three serious shapes. Pick application routing when every request needs a fresh decision; the stable hostname reaches a global entry point, which then selects a region. That provides fine control, but the entry point sits on the data path and needs its own failure and latency budget.

Pick DNS answers that vary by requester geography when approximate client location is the main input. Resolver behavior and caching mean the authoritative decision is not equivalent to a per-request decision. It also makes a tenant-level flag harder to inspect because two resolvers may legitimately observe different answers.

Pick a stable tenant CNAME to a regional pool when assignment changes are infrequent and explainability matters more than instant movement. The record graph reads like a diagram in words: school hostname points to regional pool; regional pool points to the serving edge. One tenant flag changes the first arrow.

The sharp edge is propagation. DNS data is cached, and a successful write only proves that a control-plane request was accepted. It does not prove that recursive resolvers observe the new target. Published and observed are different states.

Use an allowlist for region values. Never concatenate an arbitrary flag string into a DNS name. The assignment also needs a revision so an older worker cannot overwrite a newer decision after a retry.

Flags express intent. Nothing more.

type Region = "us" | "eu" | "ap";

type TenantAssignment = {
  tenantId: string;
  hostname: string;
  region: Region;
  revision: number;
};

const poolByRegion: Record<Region, string> = {
  us: "pool-us.example.net",
  eu: "pool-eu.example.net",
  ap: "pool-ap.example.net",
};

function desiredTarget(assignment: TenantAssignment): string {
  return poolByRegion[assignment.region];
}
Enter fullscreen mode Exit fullscreen mode

The feature-flag adapter should return a typed region plus the flag configuration revision. Persist the resulting assignment before publishing DNS. This makes retries deterministic and leaves an audit trail even if the flag changes again while a worker is running.

type FlagSnapshot = { region: Region; revision: number };

interface RegionFlag {
  evaluate(tenantId: string): Promise<FlagSnapshot>;
}

interface AssignmentStore {
  get(tenantId: string): Promise<TenantAssignment | null>;
  putIfNewer(value: TenantAssignment): Promise<boolean>;
}

async function captureIntent(
  tenantId: string,
  hostname: string,
  flag: RegionFlag,
  store: AssignmentStore,
): Promise<TenantAssignment> {
  const snapshot = await flag.evaluate(tenantId);
  const next = { tenantId, hostname, ...snapshot };
  await store.putIfNewer(next);
  return next;
}
Enter fullscreen mode Exit fullscreen mode

putIfNewer is the concurrency boundary. A worker holding revision 41 must not replace revision 42. That rule is small, testable, and independent of the DNS implementation.

Publish, verify, and measure drift

The provider boundary needs only an idempotent upsert and a read. Keep provider-specific authentication and request formats behind this interface. The reconciler compares normalized names because DNS names are case-insensitive, and APIs may return a trailing dot.

type AliasRecord = {
  name: string;
  target: string;
  ttlSeconds: number;
};

interface DnsControlPlane {
  upsertCname(record: AliasRecord): Promise<{ changeId: string }>;
  readCname(name: string): Promise<string | null>;
}

type ReconcileResult =
  | { status: "converged"; changeId?: string }
  | { status: "pending"; changeId: string; observed: string | null };

const normalizeName = (name: string): string =>
  name.toLowerCase().replace(/\.$/, "");

async function reconcile(
  assignment: TenantAssignment,
  dns: DnsControlPlane,
): Promise<ReconcileResult> {
  const expected = desiredTarget(assignment);
  const before = await dns.readCname(assignment.hostname);

  if (before && normalizeName(before) === normalizeName(expected)) {
    return { status: "converged" };
  }

  const { changeId } = await dns.upsertCname({
    name: assignment.hostname,
    target: expected,
    ttlSeconds: 300,
  });
  const after = await dns.readCname(assignment.hostname);

  if (after && normalizeName(after) === normalizeName(expected)) {
    return { status: "converged", changeId };
  }
  return { status: "pending", changeId, observed: after };
}
Enter fullscreen mode Exit fullscreen mode

A 300-second TTL is an explicit example, not a convergence promise. Caches can retain the previous answer until its TTL expires, while negative answers have their own caching rules. Measure the system you operate. Fast control-plane readback is useful, but resolver-side checks are what reveal the published view.

The tempting assumption is that a successful upsert closes the change. It does not. A control-plane response and a resolver answer describe different observation points, so recording only the former erases the interval operators most need to see. The three-state model keeps that interval explicit: the desired assignment is durable, the submitted change is traceable, and the observed answer can lag without being mislabeled as complete. Three regions and a 300-second example TTL are deliberate constraints here, not claims about an ideal topology. Add regions only when the serving architecture actually has another coarse pool.

Make each reconciliation emit one structured event. Avoid tenant names in low-cardinality metric labels; use a region and result label for metrics, then put the tenant ID and revision in logs or traces where high-cardinality lookup belongs.

type ReconcileEvent = {
  tenantId: string;
  hostname: string;
  desiredRegion: Region;
  desiredTarget: string;
  revision: number;
  result: ReconcileResult["status"];
  observedTarget?: string | null;
  durationMs: number;
};

function emit(event: ReconcileEvent): void {
  process.stdout.write(`${JSON.stringify(event)}\n`);
}
Enter fullscreen mode Exit fullscreen mode

Track a counter for outcomes, a histogram for reconciliation duration, and a gauge for assignments whose observed target differs from intent. Alert on sustained drift age, not one failed read. One miss can be propagation; an old mismatch is an operational problem.

Crisp distinction.

How do you roll out without hiding stale assignments?

Start with a small, named cohort in the flag system, but do not call the rollout successful when evaluation returns the new region. Success means every assignment in that cohort reaches observed state before its deadline. Pause expansion while drift is nonzero or aging.

Wait for evidence.

The rollback is another forward assignment with a higher revision. Do not restore an old database snapshot: that can erase intervening tenant changes and reintroduce stale intent. Set the cohort back to its former region, capture the new revision, reconcile, and verify again. Test four boundaries before production: an unchanged target causes no write; a stale revision loses; an accepted write with old readback returns pending; and an invalid region never becomes a hostname. For integration tests, use a delegated test zone rather than production tenant records. The useful assertion is the record graph, not the exact timing of cache expiry. This is a clear trade-off: a cohort advances more slowly, but its state can be explained from a flag revision, a DNS change ID, and an observed target rather than inferred from a green write response.

Also protect the email boundary. A tenant web subdomain and its parent domain may participate in different mail-authentication policies. DMARC describes organizational-domain discovery and DNS-published policy; moving a web CNAME does not justify copying, deleting, or inventing mail policy records. Keep web routing changes scoped to the exact owner name.

Limits to keep visible

This pattern is coarse. It cannot drain individual requests, guarantee that all recursive resolvers switch together, or replace health-aware traffic management. A CNAME also cannot coexist with other data at the same owner name under the DNS alias rules, so validate ownership before provisioning.

Use the three-state model as the operating contract: intent selects a pool, publication records the attempted change, and observation closes the loop. Stable names are the interface; measured drift is the safety signal.

References

Top comments (0)