article thumbnail
Building a Real-Time URL Monitor: A JavaScript Deep Dive
Finally understand the code you have been copy/pasting for years
12 min read
#javascript

Building a Real-Time URL Monitor: A JavaScript Deep Dive

How a simple dashboard teaches you async/await, Promises, DOM manipulation, and modern JavaScript patterns


You're staring at a wall of JavaScript code. Some functions say async, others don't. There's const everywhere except where there's let. Someone's using await but also .then(). And what's the difference between console.log and console.warn anyway?

Let's fix that confusion--permanently.

We'll dissect a real, working URL status dashboard that checks if websites are up or down. By the end, you'll understand not just what the code does, but why it's written that way. This dashboard is live here. Follow along by going there and viewing the page source (CTRL-u)


The Big Picture

Our dashboard does something simple: it pings a list of URLs and shows a green dot (fast), yellow dot (slow), or red dot (down). But under the hood, it's doing some sophisticated things:

Let's break it down.


const vs let: The Commitment Spectrum

Right at the top, we see this:

const CONFIG = Object.freeze({
  dotSize: 80,
  timeout: 5000,
  slowThreshold: 1000,
  autoRefreshInterval: 30
});

let urls = {};
let countdownTimer = null;
let countdownValue = CONFIG.autoRefreshInterval;

The rule is simple: use const by default, let when you need to reassign.

const doesn't mean "constant value"--it means "constant binding." You can't point the variable at something else. CONFIG will always reference that same object. But notice we also used Object.freeze()--that prevents the contents from changing too.

let is for variables that will change. urls starts empty but gets populated later. countdownTimer starts as null but becomes a timer ID. countdownValue ticks down every second.

Why does this matter? When you see const, you know that variable won't be reassigned anywhere in the code. It's a promise to future readers (including yourself) that reduces cognitive load.


Functions vs Async Functions: The Time Travel Problem

Here's a regular function:

function escapeHtml(str) {
  const div = document.createElement('div');
  div.textContent = str;
  return div.innerHTML;
}

And here's an async function:

async function loadSiteConfig() {
  return new Promise((resolve, reject) => {
    // ... load a script file
  });
}

What's the difference?

escapeHtml runs instantly. You call it, it does math, it returns. Done in microseconds.

loadSiteConfig does something that takes real time--loading a file from a server. It might take 50 milliseconds, or 3 seconds, or fail entirely. You can't just sit there waiting; the browser would freeze.

The async keyword tells JavaScript: "This function involves waiting. Let other code run while we wait."


Promises: IOUs for Data

A Promise is exactly what it sounds like: a promise that you'll get a value eventually.

async function loadSiteConfig() {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = `site-dashboard.js?_=${Date.now()}`;

    script.onload = () => {
      if (typeof window.urls !== 'undefined') {
        urls = window.urls;
        resolve();  // "I kept my promise!"
      } else {
        reject(new Error('urls not defined'));  // "I broke my promise."
      }
    };

    script.onerror = () => reject(new Error('Failed to load'));
    document.head.appendChild(script);
  });
}

A Promise has three states:

The resolve() and reject() functions are how you signal which outcome happened.


await: Making Async Code Readable

Without await, async code looks like this:

loadSiteConfig().then(() => {
  return checkAllUrls();
}).then(() => {
  updateTimestamp();
}).catch((error) => {
  showError(error);
});

With await, it looks like this:

try {
  await loadSiteConfig();
  await checkAllUrls();
  updateTimestamp();
} catch (error) {
  showError(error);
}

Same behavior, but the second version reads like normal code. Top to bottom, step by step.

The await keyword pauses that function (not the whole browser!) until the Promise resolves. If it rejects, it throws an error that try/catch can handle.

Here's a real example from our dashboard:

async function refreshAll() {
  try {
    await loadSiteConfig();
  } catch (e) {
    console.error('Config reload failed:', e);
    showNotification('Failed to reload configuration', 'warning');
  }
  // ... continue with refresh
}

try/catch: The Safety Net

Network requests fail. Files go missing. Servers crash. try/catch handles all of it:

async function checkUrl(name, url) {
  const start = performance.now();

  try {
    await fetch(url, { mode: 'no-cors', signal: controller.signal });
    const elapsed = Math.round(performance.now() - start);
    updateStatus(name, elapsed > threshold ? 'slow' : 'good', elapsed);
  } catch (e) {
    if (e.name === 'AbortError') {
      updateStatus(name, 'down', null);  // Timeout
    } else {
      // Network error, but might still be reachable
      const elapsed = Math.round(performance.now() - start);
      if (elapsed < timeout - 100) {
        updateStatus(name, 'good', elapsed);  // Fast fail = server responded
      } else {
        updateStatus(name, 'down', null);
      }
    }
  }
}

The try block is the happy path--what should happen. The catch block is the contingency plan.

Notice how we don't just give up when an error occurs. We check what kind of error it was. An AbortError means our timeout fired--the server is too slow. A network error that happened quickly might just be CORS blocking us (the server is actually fine).

Good error handling isn't about preventing errors. It's about recovering intelligently.


Console Methods: Not Just console.log

Most developers only use console.log(). But there's a whole family:

console.log('Normal information');
console.warn('Something suspicious but not broken');
console.error('Something is definitely broken');

In our dashboard:

function updateStatus(name, status, time) {
  const dot = document.getElementById(`dot-${name}`);

  if (!dot) {
    console.warn(`Status elements not found for: ${name}`);
    return;
  }
  // ...
}

Why console.warn instead of console.log? Because a missing element isn't expected, but it's also not a crash. The warning stands out in the console (usually yellow) and signals "you should probably look at this."

Use console.error for actual failures:

} catch (e) {
  console.error('Initialization failed:', e);
}

The difference matters when you're debugging production issues at 2 AM. Warnings and errors are easier to spot than a wall of log messages.


AJAX Without the X: Modern fetch()

"AJAX" stands for Asynchronous JavaScript and XML, but nobody uses XML anymore. The concept remains: make network requests without reloading the page.

The old way used XMLHttpRequest. The modern way uses fetch():

await fetch(url, { 
  mode: 'no-cors', 
  signal: controller.signal,
  cache: 'no-store'
});

Let's break down those options:

The timeout mechanism is clever:

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);

try {
  await fetch(url, { signal: controller.signal });
  clearTimeout(timeoutId);  // Success! Cancel the timeout.
} catch (e) {
  if (e.name === 'AbortError') {
    // The timeout fired and aborted our request
  }
}

AbortController is like a kill switch. We set a 5-second timer. If fetch doesn't complete by then, we pull the plug.


Dynamic Script Loading: Hot-Swapping Configuration

One of the coolest tricks in our dashboard is reloading configuration without refreshing the page:

async function loadSiteConfig() {
  return new Promise((resolve, reject) => {
    // Remove old script
    const existing = document.getElementById('site-config-script');
    if (existing) {
      existing.remove();
    }

    // Create new script with cache-busting
    const script = document.createElement('script');
    script.id = 'site-config-script';
    script.src = `site-dashboard.js?_=${Date.now()}`;

    script.onload = () => resolve();
    script.onerror = () => reject(new Error('Failed to load'));

    document.head.appendChild(script);
  });
}

The magic is ?_=${Date.now()}. This appends a unique timestamp to the URL, like site-dashboard.js?_=1699547823456. The browser sees a "new" URL and fetches fresh content instead of using its cache.

This means you can edit site-dashboard.js, and the next refresh picks up your changes--no page reload required.


DOM Manipulation: Surgical Updates

Old-school JavaScript would rebuild the entire page on every update. Modern code updates only what changed:

async function refreshAll() {
  const container = document.getElementById('statusContainer');
  const currentCards = new Set(Object.keys(urls));
  const existingCards = new Set();

  // Track what's already there
  container.querySelectorAll('.status-card').forEach(card => {
    const id = card.id.replace('card-', '');
    existingCards.add(id);
  });

  // Remove cards that shouldn't exist anymore
  existingCards.forEach(id => {
    if (!currentCards.has(id)) {
      const card = document.getElementById(`card-${id}`);
      if (card) card.closest('.column').remove();
    }
  });

  // Add new cards
  Object.entries(urls).forEach(([name, entry]) => {
    if (!existingCards.has(name)) {
      container.insertAdjacentHTML('beforeend', createStatusCard(name, url));
    }
  });
}

We use Set objects to efficiently track which cards exist and which should exist. Then we compute the difference:

The result: no flicker, no jumping scroll position, no lost state.


Promise.allSettled: Parallel Execution Done Right

When checking multiple URLs, we don't want to check them one at a time:

// Slow: sequential
for (const [name, url] of Object.entries(urls)) {
  await checkUrl(name, url);  // Wait for each one
}

// Fast: parallel
const checkPromises = Object.entries(urls).map(([name, url]) => 
  checkUrl(name, url)
);
await Promise.allSettled(checkPromises);

Promise.allSettled waits for all promises to complete, whether they succeed or fail. This is different from Promise.all, which fails fast--if one promise rejects, everything rejects.

For a status dashboard, we want to know the status of all URLs, even if some fail. Promise.allSettled gives us that.


Security: Escaping User Input

Any time you insert dynamic content into HTML, you risk XSS (cross-site scripting) attacks. Our escapeHtml function prevents that:

function escapeHtml(str) {
  const div = document.createElement('div');
  div.textContent = str;
  return div.innerHTML;
}

This is a clever trick. Setting textContent treats the string as plain text, automatically escaping any HTML. Then we read it back via innerHTML.

Input: <script>alert('hacked')</script> Output: &lt;script&gt;alert('hacked')&lt;/script&gt;

Always escape dynamic content. Always.


Putting It All Together

Here's what happens when you load the dashboard:

  1. DOMContentLoaded fires, calling init()
  2. init() calls loadSiteConfig(), which dynamically loads site-dashboard.js
  3. Once loaded, we build the initial HTML for each URL card
  4. We fire off parallel fetch() requests for all URLs
  5. As each request completes (or times out), we update that card's status
  6. If auto-refresh is enabled, a countdown starts
  7. When the countdown hits zero, we repeat from step 2

All of this happens without ever reloading the page. The configuration is reloaded fresh each time, so changes take effect immediately.


Your Challenge

Now that you understand the code, try extending it:

  1. Add sound alerts: Play a sound when a URL goes down
  2. Add history: Track response times over the last hour and show a mini chart
  3. Add categories: Group URLs by type (production, staging, external services)
  4. Add notifications: Use the Notification API to alert even when the tab is in the background

The best way to learn JavaScript is to modify working code. You now have a solid foundation--go build something.


Found this useful? Share it with a developer friend who's still confused about async/await.