How a React Effect Turned Cloud Sync Into an API Request Loop

A production debugging lesson from Office Escape

By

One of the more annoying bugs I hit while building Office Escape did not break the UI.

The app still opened. The countdown still worked. Nothing obviously crashed.

But after deployment I noticed something much worse from an infrastructure point of view: the browser was hitting the backend again and again.

The problem was not an intentionally aggressive polling feature. It was a synchronization effect that kept re-entering.

Why this was easy to miss

Office Escape stores user settings and progress in the cloud, but the actual countdown should not depend on a server request every second.

The intended model was simple:

load user state

keep active state locally

calculate countdown locally

sync only when meaningful data changes

What happened in practice was more subtle.

During hydration and initialization, several pieces of state changed close together. Some of the objects used by the sync effects were also being recreated across renders.

A simplified version of the dangerous pattern looks like this:

useEffect(() => {
  if (!user) return
 
  saveSettings(settings)
}, [user, settings])

That code is not automatically wrong.

The problem starts when settings is reconstructed frequently, or when a write causes another state update, which creates another object, which satisfies the effect again.

Now add initialization logic, a cloud read, local hydration, streak/progress state, and React development lifecycle behavior, and a harmless-looking effect can become a request generator.

The root problem was ownership of synchronization

The hardest clarification was deciding which layer was allowed to say:

This state now needs to be written to the server.

Initially, too many state transitions could reach that conclusion.

The cloud-sync effects could re-enter while hydration was still settling. Initialization state was changing before the user-specific state was fully considered ready. Some object dependencies were not stable enough to represent a real semantic change.

So the system had a synchronization mechanism, but not a strong synchronization boundary.

That is a different problem from simply "too many useEffects".

First rule: a countdown tick should never mean a network request

A workday timer changes constantly, but the underlying schedule usually does not.

These are different kinds of state:

Persistent state
- work start/end time
- breaks
- preferences
- streak/progress data
 
Derived state
- current countdown
- overtime value
- percentage through the day

The derived values belong in memory and should be recalculated from timestamps.

They should not be persisted on every tick.

This sounds obvious after writing it down, but timer-heavy UIs make it easy to accidentally couple rendering frequency with synchronization frequency.

My rule now is:

High-frequency display state should almost never be the trigger for cloud persistence.

I added explicit run ownership

The next part of the fix was making sure only one synchronization run for a user owned a particular operation at a time.

Conceptually:

if (inFlightForUser.current === user.id) {
  return
}
 
inFlightForUser.current = user.id
 
try {
  await syncUserState()
} finally {
  if (inFlightForUser.current === user.id) {
    inFlightForUser.current = null
  }
}

The real implementation had to account for cleanup and a new run replacing an older one, but the important idea was ownership.

Without that, two renders can both decide they are the one responsible for syncing.

Stable dependencies were necessary, but not sufficient

I also stabilized effect dependencies and moved values into refs where a changing object identity should not mean "run again".

But I did not want the fix to depend entirely on perfect React dependency management.

A network write should also be able to protect itself from duplicates.

So I added content-based deduplication.

Before writing settings, progress, or streak data, the client can fingerprint the meaningful payload and compare it with the last successfully synchronized version.

Conceptually:

const fingerprint = JSON.stringify(normalizeSettings(settings))
 
if (fingerprint === lastSynced.current) {
  return
}
 
await saveSettings(settings)
lastSynced.current = fingerprint

In a larger system I would use a stable serializer or explicit version value instead of raw JSON.stringify, but the design principle is the important part:

rerendered is not the same as changed.

Retries also need a boundary

Another thing I wanted to avoid was solving an accidental request loop while introducing an intentional retry loop.

Transient failure handling is useful, but every retry policy needs a limit.

For this flow I kept retries bounded rather than continuously retrying in the background.

That makes failure visible and prevents a temporary backend problem from turning every open browser tab into a retry worker.

React Strict Mode was useful here

It is easy to blame Strict Mode when an effect runs more than expected in development.

I do not think that is the useful conclusion.

Strict Mode exposed the fact that the synchronization code was not idempotent enough.

If running setup/cleanup more than once can create duplicate writes, there is probably a real lifecycle weakness waiting to appear through navigation, hydration, reconnects, account changes, or component remounts anyway.

I started treating repeated development execution as a test of effect safety rather than something to work around.

The model I use now

For user state that must exist both locally and remotely, I try to make the lifecycle explicit:

UNINITIALIZED

LOAD LOCAL/CLOUD STATE

READY

LOCAL CHANGE

DIRTY

SYNC IN FLIGHT

SYNCED

That is much easier to reason about than multiple effects independently checking pieces of state and deciding whether they should read or write.

The UI can still use React hooks, but the state transition itself has a clear meaning.

What I learned

The bug looked like an API problem because the symptom was API traffic.

It was really a client-side state ownership problem.

The fixes that mattered were:

  • fetch user state once and cache it
  • calculate timer values locally
  • do not treat object identity as semantic change
  • prevent overlapping sync runs
  • deduplicate writes by meaningful content
  • bound retries
  • make initialization a real state, not a loose collection of booleans

The broader lesson is that synchronization deserves architecture even in a small product.

Once the same state lives in React, local storage, Supabase, a PWA, and a browser extension, "just put it in a useEffect" stops being a strategy.

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

© 2026 Anuka Mithara. All rights reserved.

GitHubLinkedInanukamithara.com
Close Menu