What sleep/wake bugs taught us about Supabase, network recovery, and building reliable SaaS applications.
Imagine spending weeks building a React application.
Authentication works. Database queries work. Real-time updates work. Your dashboard loads quickly, and your end-to-end tests are passing.
You deploy it.
Everything looks great.
Then someone closes their laptop.
They return an hour later, open the lid, and continue working.
The application is still there. They're still logged in. The buttons are visible.
But something is wrong.
A request never finishes. A loading indicator keeps spinning. A piece of information disappears from the screen.
Refreshing the page fixes everything.
Sound familiar?
This became one of the most interesting reliability challenges we encountered while building Axon OSINT, a SaaS platform for digital investigations.
And the lesson was bigger than any particular library:
A web application that works after a fresh page load is not necessarily an application that survives real life.
1. The Bug That Didn't Look Like a Bug
One of the frustrating things about sleep/wake failures is that the application often doesn't crash.
There's no dramatic error screen.
No obvious exception.
No broken deployment.
Instead, the failure can be subtle.
A dashboard displays information from an earlier session. A request stays pending. An action appears to be running, but nothing happens.
During our work on Axon OSINT, we encountered variations of these problems across multiple areas, including investigation workspaces, account settings, and billing workflows.
The recurring pattern was particularly interesting.
A fresh reload could restore normal behavior.
But the application was supposed to keep working without asking users to refresh their browsers every time they returned to their desks.
That raised a question:
What exactly changes when a laptop sleeps, if the React application itself is still mounted?
2. Your JavaScript State Can Survive Longer Than Your Network State
When a browser tab goes into the background or a computer sleeps, several things can change.
JavaScript timers may be delayed.
Network connections may be interrupted.
Authentication credentials may approach expiration.
Real-time connections may disconnect.
An asynchronous operation that was started earlier may not complete when expected.
Meanwhile, React component state can remain in memory.
So after the user returns, the interface may still look exactly as it did before.
This creates a dangerous mismatch:
The UI remembers a healthy application, while the network may no longer be healthy.
Consider a basic loading pattern:
async function loadData() {
setLoading(true);
const { data, error } = await supabase
.from("items")
.select("*");
if (error) {
throw error;
}
setItems(data);
setLoading(false);
}
This code has an immediate problem: if the operation throws, setLoading(false) is never reached.
We can improve that:
async function loadData() {
setLoading(true);
try {
const { data, error } = await supabase
.from("items")
.select("*");
if (error) throw error;
setItems(data);
} catch (error) {
showError(error);
} finally {
setLoading(false);
}
}
Better.
But there's still a problem.
What if the promise never settles within a reasonable amount of time?
The finally block won't execute until the awaited operation settles.
A loading indicator can remain visible indefinitely if nothing imposes a deadline.
This became one of our most important rules:
Every critical network action needs a predictable way to finish.
3. We Stopped Treating the SDK as the Only Transport
Axon OSINT uses React, TypeScript, Supabase, and Zustand.
Supabase provides a convenient SDK for database operations, authentication, and other backend functionality.
Under normal conditions, the SDK is exactly what we want.
But our post-wake engineering standard addresses a specific failure mode: after sleep or tab suspension, some SDK-dependent operations may stall even while the application still appears usable.
Our response was not to abandon Supabase.
Instead, we separated two concerns:
- What operation does the application need to perform?
- Which transport can safely perform it right now?
For critical operations, our architecture can use the SDK with a bounded timeout and an alternative direct HTTP route when appropriate.
Supabase exposes REST and RPC endpoints, so not every operation has to depend on the same client-side execution path.
A simplified read strategy looks like this:
User requests data
|
v
Is the SDK marked degraded?
|
+----+----+
| |
Yes No
| |
v v
Direct Try SDK with
HTTP a timeout
|
+----+----+
| |
Success Failure
| |
v v
Return Direct HTTP
data fallback
The distinction matters.
If we already know that the SDK has become unreliable in the current browser session, repeating the same problematic path can waste time.
Instead, eligible reads can use the alternative route immediately.
This is a resilience pattern, not a universal recommendation to bypass every SDK whenever something goes wrong.
The fallback still needs correct authentication, authorization, error handling, and data validation.
4. A Timeout Is a Deadline, Not a Recovery Strategy
A timeout is useful because it prevents the UI from waiting forever.
But a timeout alone doesn't solve the underlying problem.
Here's a simplified helper:
function withTimeout<T>(
operation: PromiseLike<T>,
timeoutMs: number
): Promise<T> {
return Promise.race([
Promise.resolve(operation),
new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error("Operation timed out"));
}, timeoutMs);
}),
]);
}
This illustration puts a deadline on waiting.
However, it doesn't actually cancel the underlying network request.
That's an important difference.
Promise.race() can stop your code from awaiting an operation, while the original request may still be running.
For cancellable direct HTTP requests, AbortController is a better tool for enforcing network deadlines.
For example:
async function fetchWithDeadline(
url: string,
init: RequestInit,
timeoutMs = 8000
) {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response;
} finally {
clearTimeout(timeout);
}
}
The important design decision isn't whether to use 8, 10, or 15 seconds.
It's that the operation has an explicit boundary.
After that boundary, the application must make a decision.
Retry safely?
Use a different transport?
Display an error?
Preserve previously loaded data?
It cannot simply remain stuck.
One crucial caveat: a timed-out write may have succeeded on the server.
Blindly retrying a payment, record creation, or other non-idempotent action can introduce duplicate operations.
Writes need stronger safeguards, such as idempotency keys, reconciliation, or a status check before retrying.
5. We Introduced a Session-Level Degraded Mode
One useful decision in our architecture was to remember when a critical SDK operation had timed out.
Rather than repeatedly trying the same failing path, we introduced a session-level degraded state.
The concept is straightforward:
type SdkHealth = {
degraded: boolean;
reason?: string;
};
When a critical operation encounters a qualifying failure, the application can mark the SDK as degraded for the current browser session.
Then subsequent eligible actions can avoid unnecessary waits.
This isn't about declaring the entire backend offline.
The backend might be completely healthy.
The problem may exist in a particular browser-side request path.
It's an important distinction:
An unhealthy client connection does not necessarily mean an unhealthy server.
Our architecture deliberately keeps this degraded state until a hard page reload, rather than repeatedly switching back and forth using an automatic timer.
Why?
Because oscillating between healthy and degraded modes can create another category of problems.
One request works. Another hangs. The system retries. Then the user clicks again.
Suddenly one intermittent failure has become a cascade of activity.
Stability sometimes means choosing a conservative path and sticking to it for the session.
6. Authentication Recovery Must Not Depend on the Broken Path
There is another subtle failure mode.
Imagine you've detected an SDK problem and decided to use direct HTTP.
Before the fallback request, you try to retrieve the access token using another asynchronous SDK call.
What happens if that call depends on the same degraded client behavior?
Your fallback can hang too.
This is why our internal post-wake standard calls for using the application's already-maintained authentication state when appropriate.
In Axon OSINT, Zustand stores session information used by critical recovery flows.
The general principle is:
SDK request becomes unreliable
|
v
Use a verified, existing session token
|
v
Make the authorized fallback request
|
v
Handle success, expiration, or failure
This does not mean ignoring authentication expiration or inventing a token.
A cached token must still be valid for the request, and the server must enforce authorization on every path.
If no valid token is available, the application should follow a controlled authentication recovery flow.
A fallback is not a security bypass.
Resilience and authorization must work together.
7. Recovering Every Component at Once Can Make Things Worse
At first, it might seem that the solution to wake-related bugs is simple:
Whenever the browser tab becomes visible, refetch everything.
Unfortunately, this can introduce an entirely new problem.
Imagine your application has:
- An investigations list.
- A graph workspace.
- A notification panel.
- Account settings.
- A billing summary.
- Several background queries.
If every component independently reacts to a focus or visibilitychange event, returning to the tab can trigger many simultaneous requests.
Some may overlap.
Others may compete for state updates.
A few might fail while the others recover.
We've had to consider this problem in Axon OSINT as well.
Our recovery architecture tracks wake cycles and avoids treating every focus event as a completely new recovery operation.
It also distinguishes between ordinary tab focus and a situation that actually requires recovery.
The key lesson:
Recover the application, not every component independently.
One coordinated recovery process is easier to reason about than a dozen unrelated event listeners.
8. The UI Must Be Honest About What It Knows
Here's a particularly dangerous response to network failure:
try {
return await loadSubscription();
} catch {
return null;
}
At first glance, it looks harmless.
But what does null mean?
Does the user have no subscription?
Or did the network fail?
Those are completely different states.
If the application treats a transport failure as an absence of business data, it may hide information that still exists on the server.
The same issue applies to returning an empty array when a request fails.
An empty list might mean there are no investigations.
Or it might mean the application couldn't load them.
A reliable interface should distinguish these cases.
For example:
type LoadResult<T> =
| { status: "success"; data: T }
| { status: "stale"; data: T }
| { status: "error"; message: string };
Now the application can display previously confirmed information without pretending that a failed network request returned an empty result.
But cached information must be clearly understood as potentially outdated.
And sensitive actions should still require fresh, authorized server confirmation.
This distinction became especially important for workflows involving account status, permissions, and billing.
A network error is not a business decision.
9. We Learned That Background Writes Need Their Own Rules
Our investigation workspace includes graph-based interactions.
Users may move entities around, organize relationships, and update layouts.
Some of these operations generate background writes.
Imagine moving dozens of nodes, then returning from sleep while the application attempts to persist every position at once.
A naive implementation might look like:
await Promise.all(
nodes.map((node) => savePosition(node))
);
For a large graph, this can create a burst of network requests.
If the network is degraded, the application may produce multiple timeouts and errors, even though saving node positions is not the user's most important task.
Our engineering guidelines therefore distinguish between critical actions and non-critical persistence.
For position updates and similar background operations, we favor concepts such as:
- Queuing.
- Deduplication.
- Debouncing.
- Bounded concurrency.
- Summarized failures.
The user should be able to continue interacting with the graph without a non-critical persistence problem freezing the entire interface.
But the application must also avoid pretending that unsaved changes were successfully persisted.
The right trade-off depends on the operation.
A graph position and a financial transaction should never share the same retry policy.
10. The Tests That Matter Happen After the Happy Path
A typical frontend test might do this:
Open the page
Click a button
Verify the result
Done
That's useful.
But it doesn't cover the conditions that exposed our reliability problems.
Our sleep/wake test scenarios also consider situations such as:
Open the application
Authenticate
Open an investigation
Put the laptop to sleep
Wake it
Try to create an entity
Switch between graph and table
Verify the UI reaches a terminal state
Other scenarios cover network interruption, security settings, account sessions, and post-wake actions.
The desired result isn't always that every request succeeds.
Sometimes the network genuinely isn't available.
What matters is that the application behaves predictably.
No infinite loading.
No blank screen.
No uncontrolled request storm.
No misleading empty data.
No duplicate writes caused by unsafe retries.
And no action that leaves the user wondering whether it happened.
That final point matters more than it might sound.
For users, uncertainty about whether an action succeeded can be worse than a clear error message.
11. Our Reliability Checklist Changed
After working through these issues, we began asking different questions during development.
Not just:
"Does this action work?"
But:
Does it work after the browser has been suspended?
What happens if the SDK request stalls?
Can the user recover without refreshing the entire page?
Will a timeout leave an action stuck in loading state?
Could retrying this operation create duplicate data?
Does an empty response mean no records exist, or that something failed?
Will multiple components initiate recovery simultaneously?
Does the fallback preserve the same authorization boundaries?
These questions are now part of how we approach critical workflows.
And there is an important qualification: architecture standards don't automatically eliminate every regression.
New features can still introduce bugs if they don't follow the same rules.
Reliability is a property that has to be maintained, not a checkbox that can be completed once.
The Most Important Lesson
When we started building Axon OSINT, our attention was naturally focused on features.
Investigations. Entities. Relationships. Collaboration. Billing. AI-assisted workflows.
All of those are important.
But real users don't always interact with applications under ideal conditions.
They close their laptops.
They switch between tabs.
Their internet connection drops.
Their session changes while the application is in the background.
They return to something they started earlier and expect to continue.
That is normal behavior.
And a well-designed SaaS application must treat it as normal.
The real test of a web application isn't only whether it works when everything goes right. It's whether it remains understandable, secure, and recoverable when something goes wrong.
That's one of the biggest engineering lessons we've learned while building Axon OSINT.
And we're still learning.
Let's Discuss
Have you experienced bugs that only appear after laptop sleep, browser suspension, or network reconnection?
How do you handle stalled requests, background recovery, and duplicate-action prevention in your React applications?
I'd love to hear what has worked for you.
We're building Axon OSINT, a platform for digital investigations, OSINT research, and collaborative evidence analysis.
This article is part of our **Building Axon OSINT* engineering series. The code snippets are simplified educational illustrations, not verbatim production code.*
Top comments (0)