DEV Community

Roberto Luna
Roberto Luna

Posted on

Debugging a Stale‑Cache Button in a Full‑Stack React App – Full Chronology & Fix

Debugging a Stale‑Cache Button in a Full‑Stack React App – Full Chronology & Fix

TL;DR: The “Submit” button kept showing a stale “processing” state because the server‑side cache wasn’t being invalidated after the mutation. I documented every retry attempt (5 ×) and exposed the cache key in the codebase, then added an explicit cache‑bust in the API layer. The fix cleared the UI lock‑up and made the production build deterministic.


The Problem

In the VS project the Submit button on the Gantt‑style milestone editor would sometimes stay highlighted in red after a successful mutation. The UI displayed:

[Error] Unexpected token '<' in JSON at position 0
Enter fullscreen mode Exit fullscreen mode

or, more subtly, the button remained disabled even though the network request had returned 200 OK. The root cause turned out to be a server‑side response cache that stored the previous “in‑progress” payload and served it on subsequent GETs, overriding the fresh state sent by the client.

Symptoms:

  • Button stuck in loading CSS class (cursor: wait; opacity: .5).
  • No re‑render despite Redux store receiving the SUBMIT_SUCCESS action.
  • Docker logs showed Cache hit for /api/milestones/:id even after the mutation.

What I Tried First

My initial approach was to rely on client‑side state only:

// ui.tsx (original)
const [isSubmitting, setSubmitting] = useState(false);

const handleSubmit = async () => {
  setSubmitting(true);
  await api.submitMilestone(data);
  setSubmitting(false);
};
Enter fullscreen mode Exit fullscreen mode

I added a setSubmitting(false) after the API call, assuming the UI would always reflect the latest state. That worked on my local machine but failed in the staging environment where Docker’s Nginx reverse proxy had proxy_cache enabled.

I also tried forcing a React re‑render by toggling a dummy state, but the button still read the cached HTML fragment from the server.

Both attempts ignored the fact that the server was serving a stale HTML fragment that included the button’s disabled attribute. The client could not override the server‑rendered markup because the page was being hydrated from the cached response.


The Implementation

1. Document the Chronology

First, I added a chronology markdown to capture every retry attempt. This lives in CLAUDE.md and CLAUDE_CODE_CONTEXT.md. The added section looks like this (excerpt from the diff):

## Button Retry Chronology (5 attempts)

1. **Attempt 1** – Clicked, UI entered `loading` state, server responded 200 but cache returned stale HTML.
2. **Attempt 2** – Forced a full page reload (`window.location.reload()`), cache still hit.
3. **Attempt 3** – Disabled Nginx proxy_cache temporarily, button behaved correctly.
4. **Attempt 4** – Added `Cache-Control: no-store` header in API response, still hit due to upstream cache.
5. **Attempt 5** – Implemented explicit cache‑bust token (see below). ✅ Resolved.
Enter fullscreen mode Exit fullscreen mode

The documentation also records the root cause:

The cache key was generated from the request URL only (/api/milestones/:id). The mutation endpoint (POST /api/milestones/:id) did not invalidate the key, so the GET request after the mutation served the stale fragment.

2. Expose the Cache Key in Code

I added a small helper in src/utils/cache.ts to make the cache key visible to developers:

// src/utils/cache.ts
export const getMilestoneCacheKey = (id: string): string => {
  // Explicitly include the mutation timestamp to force invalidation
  return `milestone:${id}:v${Date.now()}`;
};
Enter fullscreen mode Exit fullscreen mode

And updated the API wrapper:

// src/api/milestones.ts
import { getMilestoneCacheKey } from '../utils/cache';

export const fetchMilestone = async (id: string) => {
  const cacheKey = getMilestoneCacheKey(id);
  const response = await fetch(`/api/milestones/${id}`, {
    headers: {
      'Cache-Control': 'no-store',
      'X-Cache-Key': cacheKey,
    },
  });
  return response.json();
};

export const submitMilestone = async (id: string, payload: Milestone) => {
  const response = await fetch(`/api/milestones/${id}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // Invalidate downstream caches
      'X-Invalidate-Cache': `milestone:${id}`,
    },
    body: JSON.stringify(payload),
  });
  // Force a fresh fetch after mutation
  await fetchMilestone(id);
  return response.json();
};
Enter fullscreen mode Exit fullscreen mode

3. Nginx Cache Invalidation Rule

In the Docker‑compose setup, I added a cache‑purge location to nginx.conf:

# nginx.conf (excerpt)
location /api/milestones/ {
    proxy_pass http://api:3000;
    proxy_cache my_cache;
    proxy_cache_key $uri$is_args$args;

    # Invalidate on POST
    if ($request_method = POST) {
        add_header X-Cache-Status $upstream_cache_status;
        proxy_cache_bypass $http_x_invalidate_cache;
        proxy_no_cache $http_x_invalidate_cache;
    }
}
Enter fullscreen mode Exit fullscreen mode

The X-Invalidate-Cache header sent from submitMilestone now tells Nginx to bypass and purge the cached GET response for that milestone.

4. UI Update – Hover SVG Fix

While documenting the button, I also refined its hover state. The diff added a subtle SVG “X” instead of the previous emoji:

// components/SubmitButton.tsx (new)
const HoverIcon = () => (
  <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
    <path d="M1 1L11 11M11 1L1 11" stroke="#FF5252" strokeWidth="2"/>
  </svg>
);

export const SubmitButton = ({ disabled }: { disabled: boolean }) => (
  <button
    className={`submit-btn ${disabled ? 'disabled' : ''}`}
    disabled={disabled}
  >
    {disabled ? <HoverIcon /> : 'Submit'}
  </button>
);
Enter fullscreen mode Exit fullscreen mode

Corresponding CSS (added in src/styles/button.css):

.submit-btn {
  background: #fff;
  border: 1px solid #ccc;
  transition: background .2s;
}
.submit-btn:hover:not(.disabled) {
  background: #f9f9f9;
}
.submit-btn.disabled:hover {
  background: #fff; /* keep flat when disabled */
}
Enter fullscreen mode Exit fullscreen mode

5. Production Build Verification

After the changes, I ran a full production build (npm run build && docker compose up -d). The build succeeded, compiled the entire app (including the shared ui.tsx), and the deployment script confirmed:

✔ Production build completed – 2.34 s
✔ Docker images up‑to‑date
✔ Cache invalidation active on POST /api/milestones/:id
Enter fullscreen mode Exit fullscreen mode

Manual testing across three browsers (Chrome, Firefox, Edge) showed the button resetting to its enabled state instantly after a successful submission.


Key Takeaway

Never assume a successful mutation clears server‑side caches. Explicitly tie cache keys to mutable resources and purge them on write operations; otherwise UI state can become out‑of‑


Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.

Repo: zaerohell/VS · 2026-08-12

#playadev #buildinpublic

Top comments (0)