D
P
0

Web Development

Features That Are “Done” but Silently Dead: JS↔REST Contract Drift

June 27, 2026·4 min read
Features That Are “Done” but Silently Dead: JS↔REST Contract Drift

While auditing a multi-listing site I help maintain, I found four features that were, on paper, completely "done" — the buttons were there, the code was clean, and they had passed code review months ago. The problem: all four were silently dead. You clicked the button, you got the hover effect, the cursor changed, and then... nothing happened. No toast, no DOM change, no navigation. And the cruelest part: not a single error in the console.

The symptom was always the same. Take the "Save to favorites" button. I clicked it, the cursor went busy for a beat, then settled back as if it had worked. But the favorites list never changed. When I opened DevTools, the console was spotless. Here's the JS behind it:

async function saveListing(id) {
  const res = await fetch(`/wp-json/app/v1/listing/${id}/favorite`);
  const data = await res.json();
 
  if (!data.listing_id) return; // silently bails right here
 
  showToast(`Saved: ${data.title}`);
  document.querySelector('.favorite-count').textContent = data.count;
}

Nothing is syntactically wrong. No exception is thrown. This function does its job perfectly — which, as it turns out, is to do nothing at all.

Why this happens

The root cause comes down to one word: drift. The server side had evolved; the front end had not followed. The contract between JS and REST that once lined up had slowly slipped out of alignment, and nobody noticed.

Across the four features the specifics differed, but the disease was identical:

  • A renamed JSON field. The endpoint used to return listing_id; now it returns id. The JS still reads data.listing_id, gets undefined, and returns.
  • A changed response shape. The server once replied with a bare array; now it wraps it as { data: [...] }. The JS loops over what is now an object and gets zero iterations.
  • A moved/renamed selector. A markup refactor swapped .favorite-count for .listing__fav-count. querySelector returns null, and the write to .textContent lands on nothing.
  • An endpoint that now requires a param. The server added a mandatory parameter (say context) that used to be optional. Without it, the response shape changes and the field the JS waits for is gone.

What makes this so slippery: in every case, the JS reads undefined and early-returns — a polite no-op. Nothing throws. fetch still gets an HTTP 200. res.json() still parses valid JSON. It just isn't the shape the JS expected. So the button looks alive while being thoroughly dead.

And this is exactly why code review passed. When reviewed, the JS side looked internally correct — the fetch call was tidy, and the if (!data.x) return guard even read like good defensive coding. The PHP side looked correct too — the endpoint returned valid JSON in its new shape. Each side was self-consistent. What was invisible on paper was the drift between them. A reviewer reads two separate files, not the single live conversation between the browser and the server.

The fix

I stopped trusting "the code looks right." I verified every JS↔REST and JS↔DOM call end-to-end in a real browser.

The method is simple but disciplined: click the button, then open the Network tab. I watch the request that actually goes out, then I read the real response — not the response I imagined. The moment I saw the payload, the drift was obvious: id, not listing_id. Then I confirmed the side effect actually happened — the toast appeared, .textContent changed, the navigation fired — rather than assuming "the code should do that."

Once the real field names were in front of me, the fix was just realigning the contract:

async function saveListing(id) {
  const res = await fetch(`/wp-json/app/v1/listing/${id}/favorite?context=view`);
  const data = await res.json();
 
  if (!data.id) {
    console.warn('[favorite] response is missing field "id"', data);
    return;
  }
 
  showToast(`Saved: ${data.title}`);
  const counter = document.querySelector('.listing__fav-count');
  if (!counter) {
    console.warn('[favorite] selector .listing__fav-count not found');
    return;
  }
  counter.textContent = data.count;
}

Notice the console.warn lines. They are not decoration. They are a thin runtime guard that makes drift loud. The next time the server renames a field or someone moves a selector, this code does not die quietly — it shouts in the console, naming exactly what went missing. The polite no-op is the enemy; I want vocal failure.

Four features, four contract realignments of the same kind, all found by one thing: clicking the button in a real browser and watching the Network tab.

The takeaway

A passing code review cannot catch contract drift between client and server. Reviewers see two sides that are each correct and conclude the whole is correct — but the thing that broke is the seam in the middle, which lives in no single file.

The only proof that JS and REST still agree is one real click in a real browser: fire the real request, read the real response, confirm the real side effect. A feature isn't "done" because the code looks right. A feature is done when the button actually does something when you press it. And if you add a guard that gets loud when an expectation is missed, the next drift surfaces in seconds — not months later, when some auditor idly presses every button.