Calculate distance between ZIP codes
Build a browser calculator for straight-line distance between two US ZIP codes, with results in miles and kilometers.
On this page
Working example
HTML + JavaScriptBefore you start
You'll build a calculator with two US ZIP inputs and a result in miles and kilometers. One API request returns both measurements and identifies the two places. The complete example is a single HTML file with plain JavaScript.
Create a Public key on the example setup and add localhost to its allowed domains. Add your production hostname when you publish the calculator. Use the parse_public_ key in this browser example and keep parse_ secret keys on your server.
Copy the complete example at the bottom of this page into index.html, then replace YOUR_PUBLIC_API_KEY with your public key. Open a terminal in that file's directory and run:
python3 -m http.server 8000Visit localhost:8000. Opening the HTML directly with file:// won't provide the website origin the public key needs. The hostname in the address bar should match localhost on the key's allowed domain list.
1. Create two ZIP inputs
Label the inputs so the visitor can distinguish the starting ZIP from the destination. Use text fields for both, even though this calculator accepts only five-digit US ZIPs.
<form id="distance-form">
<div class="fields">
<label for="from">From ZIP
<input id="from" name="from" type="text" inputmode="numeric" pattern="[0-9]{5}" maxlength="5"
title="Enter a five-digit US ZIP code" value="28202" required>
</label>
<label for="to">To ZIP
<input id="to" name="to" type="text" inputmode="numeric" pattern="[0-9]{5}" maxlength="5"
title="Enter a five-digit US ZIP code" value="10001" required>
</label>
</div>
<button type="submit">Calculate distance</button>
<p id="status" role="status" aria-live="polite" aria-atomic="true"></p>
</form>Keeping the inputs as strings preserves leading zeroes. inputmode="numeric" gives mobile users a convenient keyboard without changing the value into a number. A five-digit format check helps catch incomplete input before it makes a request, but only the lookup can tell you whether a code is available.
Use a submit button to calculate the distance. That gives the visitor time to enter both ZIPs and makes the action clear. A single status message beside the form can report loading or a failed lookup without replacing either input.
2. Make the distance request
Call /postal/{code}/distance/{other}?country=US. The first path value is the starting ZIP, and the second is the destination. Encode each value as a path segment before sending it.
async function lookupDistance(start, end, signal) {
if (!API_KEY.startsWith('parse_public_')) {
throw new Error('Add your public API key and allow this hostname in its domain list.');
}
const url = new URL(`https://api.parseapi.com/postal/${encodeURIComponent(start)}/distance/${encodeURIComponent(end)}`);
url.search = new URLSearchParams({ country: 'US', key: API_KEY });
const response = await fetch(url, { signal });
if (!response.ok) {
const messages = {
400: 'Check both ZIPs. Some ZIPs have no coordinates for a distance calculation.',
401: 'Check your public API key.',
403: 'Check your public key, its allowed hostnames, and your account access.',
404: 'One or both ZIPs were not found in the covered data. Try another pair.',
429: 'The request limit was reached. Try again later.'
};
throw new Error(messages[response.status] || 'The distance lookup is unavailable. Try again shortly.');
}
const data = await response.json();
if (data?.country !== 'US' || data.from?.postal !== start || data.to?.postal !== end) {
throw new Error('The lookup returned an unexpected ZIP or country. Try again.');
}
return data;
}The country scopes both ZIPs in this request. This calculator always sends US, so a five-digit value shared with another country's postal system stays unambiguous. The endpoint answers a distance question within one country.
The response includes from and to objects, each with a postal code and an available city name. distance is the straight-line distance in kilometers, and distance_mi is the same measurement in miles. The result shows miles first and kilometers underneath. Both are already in the response, so the request needs no unit parameter or browser-side conversion.
Check the response status before reading those fields as a successful result. An unknown code and a code without coordinates need an error message, not a result with zero miles. The complete example handles a failed request while keeping the entered ZIPs available to edit.
3. Display the places and distance
Render the response's from and to values with the measurement. Those values identify which lookup produced the result, including when the API normalizes a code.
function showDistance(data) {
if (!data || !data.from || !data.to) {
throw new Error('The lookup returned an unexpected response. Try again.');
}
// Both units are returned. Never coerce a missing value into zero.
if (!Number.isFinite(data.distance) || data.distance < 0 || !Number.isFinite(data.distance_mi) || data.distance_mi < 0) {
throw new Error('Distance is unavailable for these ZIPs. Try another pair.');
}
if ([data.from, data.to].some(place => !(place.city === null || typeof place.city === 'string'))) {
throw new Error('The lookup returned an unexpected place label. Try again.');
}
const label = (place) => place.city ? `${place.postal} (${place.city})` : place.postal;
document.querySelector('#route').textContent = `${label(data.from)} to ${label(data.to)}`;
const formatted = data.distance_mi.toLocaleString('en-US', { maximumFractionDigits: 2 });
document.querySelector('#distance').textContent = `${formatted} miles`;
document.querySelector('#other-unit').textContent = `${data.distance.toLocaleString('en-US', { maximumFractionDigits: 2 })} km · approximate straight-line distance`;
result.hidden = false;
setStatus(`Distance calculated: ${formatted} miles in a straight line.`);
}Treat zero as a real measurement. Looking up the same ZIP at both ends can return zero, so a truthiness check such as if (distance) would hide a valid result. Keep missing values distinct from numeric zero.
A city label may be unavailable even when a postal code has coordinates. The ZIP still identifies the place, so display it independently of the optional city name. Use textContent for the returned values when adding them to the page.
Clear the old result when either ZIP changes. If an earlier request is still running, it must not repaint that result after the visitor has entered a new pair. The complete example keeps request state with the inputs and ignores superseded results.
4. Check the calculator
Run the calculator with the initial ZIPs. Compare both displayed distances with the fields in your browser's Network panel. Swap the inputs and calculate again. The place labels should swap, while the straight-line measurement in that unit stays the same.
Use the same ZIP in both fields to check the zero-distance case. Try an incomplete ZIP and confirm the form asks for five digits before submitting. Finally, take the browser offline and run a lookup. You should get a useful failure message, with the entered values still in place and no stale measurement shown as a new answer.
What the measurement means
The distance connects the available postal coordinates in a straight line. It describes approximate postal areas. Road mileage, travel time, and distances between particular street addresses require different measurements.
Both codes need coordinates. Some covered postal records have names or other information without a point, so a successful ordinary postal lookup does not guarantee a distance result. The Postal API reference documents this behavior alongside the response fields.
Next steps
For an open-ended search around one ZIP, find nearby ZIP codes within a radius. For an address form, autofill city and state. The Postal API has live examples of all three operations.
Complete example
One HTML file with the markup, styles, and JavaScript. Replace YOUR_PUBLIC_API_KEY with your public key, then run it on a domain you have added to that key.
View and copy the complete source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Distance between two ZIP codes</title>
<style>
:root { color-scheme: light dark; --bg: #fff; --ink: #152029; --muted: #5e6b75; --line: #d9e2e7; --field: #f6f9fa; --accent: #007c91; --button: #c5f6ff; --error: #b42335; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg: #0b1015; --ink: #eff7fa; --muted: #94a6b2; --line: #2c3942; --field: #121b23; --accent: #67e8f9; --button: #67e8f9; --error: #fda4af; }
}
:root[data-theme="dark"] { color-scheme: dark; --bg: #0b1015; --ink: #eff7fa; --muted: #94a6b2; --line: #2c3942; --field: #121b23; --accent: #67e8f9; --button: #67e8f9; --error: #fda4af; }
:root[data-theme="light"] { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; padding: 24px; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }
main { max-width: 560px; margin: 0 auto; }
p { margin: 0; }
.fields { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
label { display: block; font-size: 13px; font-weight: 550; }
input, select, button { font: inherit; }
input, select { width: 100%; min-width: 0; margin-top: 7px; min-height: 46px; padding: 11px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--field); color: var(--ink); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
button { min-height: 46px; margin-top: 14px; padding: 11px 18px; border: 0; border-radius: 7px; background: var(--button); color: #07313c; cursor: pointer; font-size: 14px; font-weight: 650; }
button:disabled { opacity: .55; cursor: wait; }
#status { min-height: 44px; margin-top: 14px; color: var(--muted); font-size: 13px; }
#status[data-error="true"] { color: var(--error); }
#result { border-top: 1px solid var(--line); padding: 20px 0; }
.distance { font-size: clamp(30px, 7vw, 46px); font-weight: 550; letter-spacing: -.045em; color: var(--accent); }
#route, #other-unit { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }
#route { margin-bottom: 8px; }
@media (max-width: 380px) { body { padding: 18px; } .fields { grid-template-columns: 1fr 1fr; } button { width: 100%; } }
</style>
</head>
<body>
<main>
<!-- example:form -->
<form id="distance-form">
<div class="fields">
<label for="from">From ZIP
<input id="from" name="from" type="text" inputmode="numeric" pattern="[0-9]{5}" maxlength="5"
title="Enter a five-digit US ZIP code" value="28202" required>
</label>
<label for="to">To ZIP
<input id="to" name="to" type="text" inputmode="numeric" pattern="[0-9]{5}" maxlength="5"
title="Enter a five-digit US ZIP code" value="10001" required>
</label>
</div>
<button type="submit">Calculate distance</button>
<p id="status" role="status" aria-live="polite" aria-atomic="true"></p>
</form>
<!-- /example:form -->
<div id="result" hidden>
<p id="route"></p>
<p id="distance" class="distance"></p>
<p id="other-unit"></p>
</div>
</main>
<script>
// Use a public key restricted to your development and production hostnames.
const API_KEY = 'YOUR_PUBLIC_API_KEY';
const form = document.querySelector('#distance-form');
const from = document.querySelector('#from');
const to = document.querySelector('#to');
const button = form.querySelector('button');
const status = document.querySelector('#status');
const result = document.querySelector('#result');
let controller;
let revision = 0;
function setStatus(message, error = false) {
status.textContent = message;
status.dataset.error = String(error);
}
function cancelRequest() {
revision += 1;
controller?.abort();
controller = undefined;
button.disabled = false;
form.setAttribute('aria-busy', 'false');
}
// example:request
async function lookupDistance(start, end, signal) {
if (!API_KEY.startsWith('parse_public_')) {
throw new Error('Add your public API key and allow this hostname in its domain list.');
}
const url = new URL(`https://api.parseapi.com/postal/${encodeURIComponent(start)}/distance/${encodeURIComponent(end)}`);
url.search = new URLSearchParams({ country: 'US', key: API_KEY });
const response = await fetch(url, { signal });
if (!response.ok) {
const messages = {
400: 'Check both ZIPs. Some ZIPs have no coordinates for a distance calculation.',
401: 'Check your public API key.',
403: 'Check your public key, its allowed hostnames, and your account access.',
404: 'One or both ZIPs were not found in the covered data. Try another pair.',
429: 'The request limit was reached. Try again later.'
};
throw new Error(messages[response.status] || 'The distance lookup is unavailable. Try again shortly.');
}
const data = await response.json();
if (data?.country !== 'US' || data.from?.postal !== start || data.to?.postal !== end) {
throw new Error('The lookup returned an unexpected ZIP or country. Try again.');
}
return data;
}
// /example:request
// example:result
function showDistance(data) {
if (!data || !data.from || !data.to) {
throw new Error('The lookup returned an unexpected response. Try again.');
}
// Both units are returned. Never coerce a missing value into zero.
if (!Number.isFinite(data.distance) || data.distance < 0 || !Number.isFinite(data.distance_mi) || data.distance_mi < 0) {
throw new Error('Distance is unavailable for these ZIPs. Try another pair.');
}
if ([data.from, data.to].some(place => !(place.city === null || typeof place.city === 'string'))) {
throw new Error('The lookup returned an unexpected place label. Try again.');
}
const label = (place) => place.city ? `${place.postal} (${place.city})` : place.postal;
document.querySelector('#route').textContent = `${label(data.from)} to ${label(data.to)}`;
const formatted = data.distance_mi.toLocaleString('en-US', { maximumFractionDigits: 2 });
document.querySelector('#distance').textContent = `${formatted} miles`;
document.querySelector('#other-unit').textContent = `${data.distance.toLocaleString('en-US', { maximumFractionDigits: 2 })} km · approximate straight-line distance`;
result.hidden = false;
setStatus(`Distance calculated: ${formatted} miles in a straight line.`);
}
// /example:result
form.addEventListener('input', () => {
cancelRequest();
result.hidden = true;
setStatus('Calculate again to use the changed values.');
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (!form.reportValidity()) return;
cancelRequest();
result.hidden = true;
const current = revision;
controller = new AbortController();
const request = controller;
let timedOut = false;
const timeout = setTimeout(() => { timedOut = true; request.abort(); }, 10000);
button.disabled = true;
form.setAttribute('aria-busy', 'true');
setStatus('Calculating the distance...');
try {
const data = await lookupDistance(from.value, to.value, request.signal);
if (current !== revision) return;
if (request.signal.aborted) throw new Error('The lookup was cancelled.');
showDistance(data);
} catch (error) {
if (current !== revision) return;
const message = timedOut ? 'The lookup timed out. Try again.'
: error instanceof TypeError ? 'Could not reach ParseAPI. Check your connection and try again.'
: error instanceof SyntaxError ? 'The response could not be read. Try again.' : error.message;
setStatus(message, true);
} finally {
clearTimeout(timeout);
if (current === revision) cancelRequest();
}
});
</script>
</body>
</html>
Need a key? Set up this browser example. For every field and parameter, see Postal API reference.
Build something else
- Autofill city and state from a ZIP code
- Choose an Australian suburb from a postcode
- Find nearby ZIP codes
- Find an open store nearby
- Add places to a ZIP-code CSV
- Build a local weather card