← Back to list

Six Bugs I Found Before Launch That Would Have Shipped Silently

A stale cache, two dead code paths, a retry loop that ignores the server. All passed review. None threw errors.

Harika Yenuga in Messy Founder · 2026-07-15 00:22 · 50 claps · 6.8 min read
#software-development #react #javascript #technology #data-science
Open on Medium ↗
Wiki topics: ML · Machine Learning 🌐 · Web Development 🔬 · Science · General

Six Bugs I Found Before Launch That Would Have Shipped Silently

A stale cache, two dead code paths, a retry loop that ignores the server. All passed review. None threw errors.

The workflow worked correctly in every dev build

I built a React Native application with a multi-step onboarding flow. The user enters profile data across four screens: basics, dietary preferences, goals, and a final review. On completion, the application writes the data to the backend and lands the user on a dashboard that displays their profile summary.

Before launch, I found bugs that looked completely correct.

Before launch, I found bugs that looked completely correct.

The dashboard reads from a useProfile hook that wraps React Query's useQuery with a staleTime of five minutes. The completeOnboarding function calls queryClient.setQueryData(['profile'], ...) to patch onboardingCompletedAt into the cached profile object. No invalidateQueries call follows the patch. During development and QA, the dashboard always showed the correct data because the profile had already been fetched and cached with current values during the onboarding flow itself.

I was preparing to launch. A pre-launch audit caught the problem before any user saw it.

Every user would have seen the wrong dashboard for up to five minutes

I ran through the onboarding flow in a staging build and landed on the dashboard. No calorie targets. No macros. No goals. The profile object existed in cache, but it contained pre-onboarding values. profile.goals was null. profile.basics reflected whatever had been fetched before I started entering data. profile.dietary was empty.

The data I had just spent two minutes entering was on the server. The dashboard did not know.

The timing was not random. The stale window was exactly five minutes, every run, without exception. Nothing broke. Nothing threw. The screen rendered a complete layout with empty fields where numbers should have been, and it looked perfectly operational while doing it.

If this had shipped, nobody would have reported it. A dashboard with blank fields after onboarding looks like a broken product. Users who encountered it would have closed the app and not returned.

The backend saved everything correctly

My first assumption was that the backend was not saving correctly. I checked the server logs in staging. Every POST returned 200. The database contained all four data sets: basics, dietary, goals, and the onboarding completion timestamp. The API was doing its job.

My second assumption was that the mutation was not completing. I traced the completeOnboarding function line by line. It completed. setQueryData ran. The cache was updated. But the update only wrote onboardingCompletedAt into the existing cached object. The rest of the profile, the fields the user had just submitted, remained whatever React Query had fetched before onboarding started.

The cache was current for one field. It was stale for everything else. And staleTime: 5 * 60 * 1000 told React Query not to check.

One writes to the cache, the other asks the server

I had been treating setQueryData and invalidateQueries as two ways of doing the same thing. They do completely different work.

setQueryData writes directly to the query cache. It does not trigger a background refetch. It does not consult the server. It patches whatever object is currently stored under the given query key with whatever data the developer provides. If the patch is incomplete, the cache is incomplete. React Query does not know the difference between a fully reconciled cache entry and a partially patched one.

invalidateQueries marks the cached data as stale and triggers a background refetch. The server responds with the full, current object. The cache gets replaced entirely.

My completeOnboarding function called setQueryData to patch one field into an object with four top-level sections. Then staleTime: 5 min told React Query the cache was fresh. Do not refetch. For five minutes.

So the dashboard read from a hybrid cache entry: one current timestamp and three stale data sections. It rendered that data without hesitation.

// What completeOnboarding did
queryClient.setQueryData(['profile'], (old) => ({
  ...(old ?? {}),
  onboardingCompletedAt: new Date().toISOString(),
}));
// What it should have also done
queryClient.invalidateQueries({ queryKey: ['profile'] });

The fix was one line. setQueryData stays because it gives the user instant UI feedback while the refetch runs. invalidateQueries tells React Query to go back to the server and get the real object. I should have been calling both from the start. The React Query docs do not make this obvious, and I did not think to question it until I walked through the full onboarding flow in staging and watched the dashboard render blank fields.

Two more functions in the same file passed review and never executed

While investigating Bug 1, I found two more functions in the same codebase that passed code review and never did what they appeared to do.

The step-4 screen had an error-handling useEffect with this guard condition:

useEffect(() => {
  if (error && editingGoal !== 'saving') {
    Alert.alert('Error', 'Failed to save goals');
  }
}, [error, editingGoal]);

The variable editingGoal is set to field names like 'dailyCalories', 'proteinG', or null. It is never set to 'saving'. The string 'saving' does not exist anywhere else in the file. The guard editingGoal !== 'saving' evaluates to true in every case, which means the condition reduces to if (error). The guard is dead code. If an error fires while the user is mid-edit in a goal sheet, the alert appears anyway, because the condition that was supposed to prevent it checks for a value that never occurs.

The same screen had a handleGetStarted function with this structure:

try {
  await completeOnboarding();
} catch (err) {
  Alert.alert('Error', err.message);
}

The completeOnboarding function catches all errors internally and dispatches a SAVE_ERROR action. It never re-throws. The catch block in handleGetStarted will never execute. The error alert it contains will never display. The function handles errors from a function that, from the caller's perspective, never fails.

Same problem as the cache bug. Both look correct in static review. A reviewer sees editingGoal !== 'saving' and assumes 'saving' is a valid state somewhere. They see try/catch around an async call and assume the call can throw. Verifying either assumption requires tracing the runtime behavior across files, and code review does not do that. It reads the code in front of it and evaluates whether the code looks reasonable. Reasonable and correct are two different things.

The retry logic had the same blind spot

The API client had retry logic for 429 responses. On a rate-limit response, it retried at 200ms, 500ms, and 1000ms intervals. Fixed backoff, three attempts, no inspection of response headers.

The HTTP specification defines a Retry-After header for 429 responses. The server sends it to tell the client exactly when to retry. In production, if the API gateway sends Retry-After: 60, this client would ignore it and retry three times within 1.2 seconds. Every retry would hit the rate limit again. The client would burn through its retry budget against a server that had explicitly asked it to wait.

// What the client did
const delays = [200, 500, 1000];
// What the server sent
// Retry-After: 60

The server told the client when to come back. The client did not read the response. It just retried on its own schedule, three times, all within 1.2 seconds, against a server that had asked for a 60-second pause.

The client had a model of reality, and it was wrong

All four bugs follow the same pattern. The client code operates on an internal model of the system state, and that model is wrong.

The dashboard cache was wrong by omission. One field was current. Four were stale. The error guard checked for a state value that did not exist anywhere in the codebase. handleGetStarted wrapped a function in try/catch, but that function swallowed its own errors and never threw. And the retry logic ignored the timing the server had explicitly communicated in the response headers.

No error messages. No crash reports. Unit tests passed because they validated each function in isolation, and these bugs did not live inside any single function. The cache bug was a gap between setQueryData and invalidateQueries. The dead code was a mismatch between what the caller assumed and what the callee actually did. And the retry logic was a client ignoring what the server had already told it.

Runtime assertion testing in staging would have caught all of them. After every state transition, fetch the profile from the server and compare it to the cache. Bug 1 would have surfaced on the first run. A coverage report on the error guard would have shown zero executions of the intended branch. The retry bug needed a mock that actually verified Retry-After header parsing, which we did not have.

What remains unresolved

The most architectural version of this problem is still open. The completeOnboarding function makes four sequential API calls: basics, dietary, goals, and onboarding-complete. If the second call fails, the first call's data is already persisted. The user's profile is in a split state. Basics saved. Dietary missing. Goals missing. Completion flag missing.

On retry, saving basics again is idempotent because the endpoint performs an upsert. But the error message says “Failed to save dietary preferences” with no mention that basics already saved. The user has no way to know what state their profile is in.

The whole function assumes sequential success. When that assumption breaks, there is no recovery path. The user sees an error message that describes one failure, and has no idea that a previous call already succeeded. The data is in a state the application was never designed to display.

I have not fixed this yet. The correct fix is a single transactional endpoint on the backend, and I have not built it. If I ship without it, every user who hits a network error on step two of four will end up with a half-written profile and an error message that does not explain what actually happened.

That is the real pattern behind all six of these bugs. The code would not crash. The screen would not go blank. The layout would render. The data would fill in. And it would be wrong, silently, with zero indication that anything failed.

If you build user-facing applications with client-side caching, audit the gap between your cache writes and your server state before you ship. I caught a one-line omission that would have given every user five minutes of wrong data and zero error messages.


메타데이터
post_id
df10cd48e123
slug
six-bugs-i-found-before-launch-that-would-have-shipped-silently-df10cd48e123
url
https://journal.messyfounder.com/six-bugs-i-found-before-launch-that-would-have-shipped-silently-df10cd48e123
canonical_url
https://journal.messyfounder.com/six-bugs-i-found-before-launch-that-would-have-shipped-silently-df10cd48e123
author_url
https://medium.com/@harikayenuga
status
ok
fetched_at
2026-07-15 20:04:32