The Only Networking Article You'd Ever Need - 2/2
The Uncomfortable Truth: Your Users Think Your App Is Broken
You’ve learned how the internet works under the hood. You understand DNS resolution, TCP handshakes, and HTTP message formats. You can trace a network request from your browser all the way to a server on the other side of the planet. Congratulations—you’re now qualified to have opinions about networking.
But here’s the problem: all that knowledge is worthless if your users think your application is broken.
And right now, they do.
You built a beautiful “Fetch Users” button that triggers a network request. When they click it on their high-speed fiber connection in your office, it works perfectly. Data appears in 200 milliseconds. You pat yourself on the back and call it done.
But when your users click that same button on their spotty 3G connection while riding the bus, something very different happens: absolutely nothing. The button sits there, mocking them. No loading spinner, no progress indicator, no feedback whatsoever. Did the click register? Is the app frozen? Is their internet down? They have no idea.
So they do what any reasonable human would do: they click it again. And again. And again. Like a frantic woodpecker, desperately trying to get your attention while your UI sits there in stubborn silence.
A UI that doesn’t communicate is a UI that breeds contempt. Today, we’re going to fix that, and a lot more.
State Management: Teaching Your UI to Communicate
The difference between a professional application and a toy demo isn’t the complexity of the backend—it’s how well the frontend communicates with the user about what’s happening.
Every asynchronous operation needs to handle at least three states:
- Loading: “I’m working on it, please wait”
- Success: “Here’s what you asked for”
- Error: “Something went wrong, here’s what happened”
Let’s rebuild our network request demo with proper state management:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SMM Professional Network Demo</title>
<link rel="stylesheet" href="style.css" />
<script src="script.js" defer></script>
</head>
<body>
<div class="container">
<h1>Professional Network Request Demo</h1>
<button id="fetch-button">Fetch Users</button>
<div id="status-display"></div>
<ul id="user-list"></ul>
</div>
</body>
</html>
/* Enhanced styles with state-specific feedback */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
margin: 0;
padding: 2rem;
min-height: 100vh;
}
.container {
max-width: 600px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 2rem;
}
button {
display: block;
width: 100%;
padding: 1rem 2rem;
font-size: 1rem;
font-weight: 600;
color: #fff;
background: linear-gradient(45deg, #4caf50, #45a049);
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 2rem;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(76, 175, 80, 0.3);
}
button:disabled {
background: linear-gradient(45deg, #666, #555);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
#status-display {
text-align: center;
font-weight: 500;
min-height: 24px;
margin-bottom: 1.5rem;
padding: 0.5rem;
border-radius: 6px;
transition: all 0.3s ease;
}
#status-display.loading {
background: rgba(33, 150, 243, 0.2);
border: 1px solid rgba(33, 150, 243, 0.3);
}
#status-display.success {
background: rgba(76, 175, 80, 0.2);
border: 1px solid rgba(76, 175, 80, 0.3);
}
#status-display.error {
background: rgba(244, 67, 54, 0.2);
border: 1px solid rgba(244, 67, 54, 0.3);
}
.user-card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(5px);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
border-left: 4px solid #4caf50;
}
interface User {
id: number;
name: string;
username: string;
email: string;
}
const fetchButton = document.getElementById(
"fetch-button"
) as HTMLButtonElement;
const statusDisplay = document.getElementById(
"status-display"
) as HTMLDivElement;
const userList = document.getElementById("user-list") as HTMLUListElement;
const API_URL = "https://jsonplaceholder.typicode.com/users";
const setState = (state: "loading" | "success" | "error", message: string) => {
statusDisplay.textContent = message;
statusDisplay.className = state;
};
const renderUsers = (users: User[]) => {
userList.innerHTML = "";
users.forEach((user) => {
const li = document.createElement("li");
li.className = "user-card";
li.innerHTML = `
<h3>${user.name} (@${user.username})</h3>
<p>📧 ${user.email}</p>
`;
userList.appendChild(li);
});
};
const fetchUsers = async () => {
// Enter loading state immediately
fetchButton.disabled = true;
fetchButton.textContent = "Loading...";
setState("loading", "⏳ Fetching users from server...");
userList.innerHTML = ""; // Clear old data
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(
`Server responded with ${response.status}: ${response.statusText}`
);
}
const users: User[] = await response.json();
// Enter success state
setState("success", `✅ Successfully loaded ${users.length} users`);
renderUsers(users);
} catch (error) {
// Enter error state
console.error("Failed to fetch users:", error);
setState(
"error",
`❌ Failed to load users: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
} finally {
// Always re-enable the button
fetchButton.disabled = false;
fetchButton.textContent = "Fetch Users";
}
};
fetchButton.addEventListener("click", fetchUsers);
Now your application communicates. Users see immediate feedback when they click the button, they understand what’s happening during the request, and they get clear information about success or failure. This is the baseline for professional UX.
But we’re just getting started.
The Performance Problem: Why Your App Feels Slow (And Expensive)
Your improved UI might communicate better, but it still has a fundamental problem: it’s wasteful. Every single click triggers a new network request, even if the user just fetched the exact same data five seconds ago.
This isn’t just a performance problem—it’s a cost problem. If you’re running your own API, every request costs you server resources, bandwidth, and potentially money. If you’re hitting a third-party API, you might be paying per request or hitting rate limits.
The solution is caching—storing local copies of data you’ve already fetched. But naive caching introduces new problems: how do you know when cached data is too old to trust? How do you handle cache failures? How do you balance freshness with performance?
Let’s implement intelligent caching:
interface User {
id: number;
name: string;
username: string;
email: string;
}
interface CachedData {
timestamp: number;
users: User[];
}
const fetchButton = document.getElementById(
"fetch-button"
) as HTMLButtonElement;
const statusDisplay = document.getElementById(
"status-display"
) as HTMLDivElement;
const userList = document.getElementById("user-list") as HTMLUListElement;
const API_URL = "https://jsonplaceholder.typicode.com/users";
const CACHE_KEY = "users_cache";
const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes
const setState = (
state: "loading" | "success" | "error" | "cached",
message: string
) => {
statusDisplay.textContent = message;
statusDisplay.className = state === "cached" ? "success" : state;
};
const renderUsers = (users: User[]) => {
userList.innerHTML = "";
users.forEach((user) => {
const li = document.createElement("li");
li.className = "user-card";
li.innerHTML = `
<h3>${user.name} (@${user.username})</h3>
<p>📧 ${user.email}</p>
`;
userList.appendChild(li);
});
};
const getCachedData = (): CachedData | null => {
try {
const cached = localStorage.getItem(CACHE_KEY);
return cached ? JSON.parse(cached) : null;
} catch (error) {
console.warn("Failed to parse cached data:", error);
localStorage.removeItem(CACHE_KEY); // Clear corrupted cache
return null;
}
};
const setCachedData = (users: User[]) => {
try {
const dataToCache: CachedData = {
timestamp: Date.now(),
users,
};
localStorage.setItem(CACHE_KEY, JSON.stringify(dataToCache));
} catch (error) {
console.warn("Failed to cache data:", error);
}
};
const isCacheValid = (cached: CachedData): boolean => {
const age = Date.now() - cached.timestamp;
return age < CACHE_DURATION_MS;
};
const fetchUsers = async () => {
fetchButton.disabled = true;
fetchButton.textContent = "Checking...";
// Check cache first
const cached = getCachedData();
if (cached && isCacheValid(cached)) {
// Use cached data immediately
const cacheAge = Math.floor((Date.now() - cached.timestamp) / 1000);
setState("cached", `⚡ Loaded from cache (${cacheAge}s old)`);
renderUsers(cached.users);
fetchButton.disabled = false;
fetchButton.textContent = "Refresh Users";
return;
}
// Fetch fresh data
fetchButton.textContent = "Loading...";
setState("loading", "⏳ Fetching fresh data from server...");
userList.innerHTML = "";
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(
`Server responded with ${response.status}: ${response.statusText}`
);
}
const users: User[] = await response.json();
// Cache the fresh data
setCachedData(users);
setState("success", `✅ Loaded ${users.length} fresh users`);
renderUsers(users);
} catch (error) {
console.error("Failed to fetch users:", error);
// Fall back to stale cache if available
if (cached) {
setState("error", `⚠️ Using stale data (network failed)`);
renderUsers(cached.users);
} else {
setState(
"error",
`❌ Failed to load users: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
} finally {
fetchButton.disabled = false;
fetchButton.textContent = "Refresh Users";
}
};
// Load data immediately when page loads
document.addEventListener("DOMContentLoaded", fetchUsers);
fetchButton.addEventListener("click", fetchUsers);
This is dramatically better. The first click might be slow (network request), but subsequent clicks are instant (cache hit). Your app feels faster, costs less to run, and still provides fresh data when needed.
But we can do even better.
The Stale-While-Revalidate Pattern: Having Your Cache and Eating It Too
Your current caching strategy has a user experience problem: it’s binary. Either the cache is fresh (instant response) or it’s stale (full loading screen). This creates a jarring experience where your app alternates between feeling lightning-fast and painfully slow.
Professional applications use a more sophisticated strategy called stale-while-revalidate:
- Show something immediately: If you have cached data, display it instantly, even if it’s stale
- Update in the background: Quietly fetch fresh data without disrupting the UI
- Replace when ready: Seamlessly swap in the new data when it arrives
This gives users the perception of instant loading while ensuring they eventually see fresh data:
interface User {
id: number;
name: string;
username: string;
email: string;
}
interface CachedData {
timestamp: number;
users: User[];
}
const fetchButton = document.getElementById(
"fetch-button"
) as HTMLButtonElement;
const statusDisplay = document.getElementById(
"status-display"
) as HTMLDivElement;
const userList = document.getElementById("user-list") as HTMLUListElement;
const API_URL = "https://jsonplaceholder.typicode.com/users";
const CACHE_KEY = "users_cache";
const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes
const setState = (
state: "loading" | "success" | "error" | "updating",
message: string
) => {
statusDisplay.textContent = message;
statusDisplay.className = state === "updating" ? "loading" : state;
};
const renderUsers = (users: User[]) => {
userList.innerHTML = "";
users.forEach((user) => {
const li = document.createElement("li");
li.className = "user-card";
li.innerHTML = `
<h3>${user.name} (@${user.username})</h3>
<p>📧 ${user.email}</p>
`;
userList.appendChild(li);
});
};
const getCachedData = (): CachedData | null => {
try {
const cached = localStorage.getItem(CACHE_KEY);
return cached ? JSON.parse(cached) : null;
} catch (error) {
console.warn("Failed to parse cached data:", error);
localStorage.removeItem(CACHE_KEY);
return null;
}
};
const setCachedData = (users: User[]) => {
try {
const dataToCache: CachedData = {
timestamp: Date.now(),
users,
};
localStorage.setItem(CACHE_KEY, JSON.stringify(dataToCache));
} catch (error) {
console.warn("Failed to cache data:", error);
}
};
const fetchFreshData = async (): Promise<User[]> => {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(
`Server responded with ${response.status}: ${response.statusText}`
);
}
return response.json();
};
const loadUsers = async () => {
const cached = getCachedData();
if (cached) {
// ALWAYS show cached data immediately
renderUsers(cached.users);
const age = Date.now() - cached.timestamp;
const ageMinutes = Math.floor(age / 60000);
if (age < CACHE_DURATION_MS) {
// Cache is fresh
setState("success", `✅ Data is fresh (${ageMinutes}m old)`);
} else {
// Cache is stale - show it but update in background
setState(
"updating",
`🔄 Showing stale data (${ageMinutes}m old), updating...`
);
try {
const freshUsers = await fetchFreshData();
setCachedData(freshUsers);
renderUsers(freshUsers);
setState("success", "✅ Updated with fresh data");
} catch (error) {
console.error("Background update failed:", error);
setState("error", "⚠️ Showing stale data (update failed)");
}
}
} else {
// No cache - must fetch fresh data
setState("loading", "⏳ Loading data...");
if (!navigator.onLine) {
setState("error", "❌ No cached data and you are offline");
return;
}
try {
const users = await fetchFreshData();
setCachedData(users);
renderUsers(users);
setState("success", `✅ Loaded ${users.length} users`);
} catch (error) {
console.error("Initial fetch failed:", error);
setState(
"error",
`❌ Failed to load data: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
}
};
const refreshUsers = async () => {
fetchButton.disabled = true;
fetchButton.textContent = "Refreshing...";
setState("loading", "⏳ Fetching fresh data...");
try {
const users = await fetchFreshData();
setCachedData(users);
renderUsers(users);
setState("success", "✅ Refreshed with latest data");
} catch (error) {
console.error("Refresh failed:", error);
setState(
"error",
`❌ Refresh failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
} finally {
fetchButton.disabled = false;
fetchButton.textContent = "Refresh Users";
}
};
// Load data when page opens
document.addEventListener("DOMContentLoaded", loadUsers);
// Manual refresh button
fetchButton.addEventListener("click", refreshUsers);
This is the pattern used by professional applications like Twitter, Gmail, and Slack. Users see content immediately, but the app continuously keeps data fresh in the background. It’s the best of both worlds: perceived performance and data freshness.
Resilience: Building Apps That Don’t Give Up
Your application now loads fast, caches intelligently, and provides great UX feedback. But it has one remaining weakness: it’s a quitter. When a network request fails, your app throws up its hands and gives up.
This is unprofessional. Networks are unreliable. Servers have hiccups. Wi-Fi connections drop. A production-grade application assumes failure is temporary and implements retry logic with exponential backoff.
The strategy is simple:
- Try the request
- If it fails, wait a short time (1 second) and try again
- If it fails again, wait longer (2 seconds) and try again
- Keep doubling the delay until you succeed or reach a maximum number of retries
This gives temporary network issues time to resolve while avoiding the mistake of hammering a struggling server:
interface User {
id: number;
name: string;
username: string;
email: string;
}
interface CachedData {
timestamp: number;
users: User[];
}
const fetchButton = document.getElementById(
"fetch-button"
) as HTMLButtonElement;
const statusDisplay = document.getElementById(
"status-display"
) as HTMLDivElement;
const userList = document.getElementById("user-list") as HTMLUListElement;
const API_URL = "https://jsonplaceholder.typicode.com/users";
const CACHE_KEY = "users_cache";
const CACHE_DURATION_MS = 5 * 60 * 1000;
// Retry configuration
const MAX_RETRIES = 3;
const INITIAL_RETRY_DELAY = 1000; // 1 second
const setState = (
state: "loading" | "success" | "error" | "updating" | "retrying",
message: string
) => {
statusDisplay.textContent = message;
statusDisplay.className =
state === "updating" || state === "retrying" ? "loading" : state;
};
const renderUsers = (users: User[]) => {
userList.innerHTML = "";
users.forEach((user) => {
const li = document.createElement("li");
li.className = "user-card";
li.innerHTML = `
<h3>${user.name} (@${user.username})</h3>
<p>📧 ${user.email}</p>
`;
userList.appendChild(li);
});
};
const getCachedData = (): CachedData | null => {
try {
const cached = localStorage.getItem(CACHE_KEY);
return cached ? JSON.parse(cached) : null;
} catch (error) {
console.warn("Failed to parse cached data:", error);
localStorage.removeItem(CACHE_KEY);
return null;
}
};
const setCachedData = (users: User[]) => {
try {
const dataToCache: CachedData = {
timestamp: Date.now(),
users,
};
localStorage.setItem(CACHE_KEY, JSON.stringify(dataToCache));
} catch (error) {
console.warn("Failed to cache data:", error);
}
};
const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
const fetchWithRetry = async (url: string): Promise<User[]> => {
let lastError: Error;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
if (attempt > 1) {
setState("retrying", `🔄 Attempt ${attempt} of ${MAX_RETRIES}...`);
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
} catch (error) {
lastError = error instanceof Error ? error : new Error("Unknown error");
if (attempt < MAX_RETRIES) {
// Calculate delay: 1s, 2s, 4s, 8s...
const delay = INITIAL_RETRY_DELAY * Math.pow(2, attempt - 1);
await sleep(delay);
}
}
}
// All retries exhausted
throw lastError;
};
const loadUsers = async () => {
const cached = getCachedData();
if (cached) {
// Show cached data immediately
renderUsers(cached.users);
const age = Date.now() - cached.timestamp;
const ageMinutes = Math.floor(age / 60000);
if (age < CACHE_DURATION_MS) {
setState("success", `✅ Data is fresh (${ageMinutes}m old)`);
} else {
setState("updating", `🔄 Showing stale data, updating...`);
try {
const freshUsers = await fetchWithRetry(API_URL);
setCachedData(freshUsers);
renderUsers(freshUsers);
setState("success", "✅ Updated with fresh data");
} catch (error) {
console.error("Background update failed after retries:", error);
setState("error", "⚠️ Update failed, showing stale data");
}
}
} else {
// No cache available
setState("loading", "⏳ Loading data...");
if (!navigator.onLine) {
setState("error", "❌ No cached data and you are offline");
return;
}
try {
const users = await fetchWithRetry(API_URL);
setCachedData(users);
renderUsers(users);
setState("success", `✅ Loaded ${users.length} users`);
} catch (error) {
console.error("Initial fetch failed after retries:", error);
setState(
"error",
`❌ Failed after ${MAX_RETRIES} attempts: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
}
};
const refreshUsers = async () => {
fetchButton.disabled = true;
fetchButton.textContent = "Refreshing...";
setState("loading", "⏳ Fetching fresh data...");
try {
const users = await fetchWithRetry(API_URL);
setCachedData(users);
renderUsers(users);
setState("success", "✅ Refreshed with latest data");
} catch (error) {
console.error("Refresh failed after retries:", error);
setState("error", `❌ Refresh failed after ${MAX_RETRIES} attempts`);
} finally {
fetchButton.disabled = false;
fetchButton.textContent = "Refresh Users";
}
};
document.addEventListener("DOMContentLoaded", loadUsers);
fetchButton.addEventListener("click", refreshUsers);
Your application is now genuinely resilient. Temporary network hiccups won’t defeat it. Server overloads that last a few seconds won’t break the user experience. Your app will keep trying, with increasing patience, until it succeeds or truly determines that the situation is hopeless.
The Professional Standard You’ve Achieved
Look at what you’ve built:
State Communication: Your UI clearly communicates loading, success, and error states. Users are never left guessing.
Intelligent Caching: You avoid redundant network requests while keeping data fresh. Your app is faster and costs less to operate.
Stale-While-Revalidate: Users see content immediately while fresh data loads in the background. Your app feels instant but stays current.
Resilient Retry Logic: Temporary failures don’t defeat your application. It persists through network hiccups with exponential backoff.
Graceful Degradation: When completely offline, your app shows cached data instead of breaking.
This is the foundation of production-ready networking. These patterns are used by every major web application you’ve ever used. You’re not just making API calls anymore—you’re engineering reliable data flow in unreliable environments.
Beyond the Basics: The Patterns That Scale
The techniques you’ve learned work for simple applications, but real-world software has additional complexities:
Request Deduplication: What if users click rapidly or multiple components need the same data? You need to ensure you don’t fire duplicate requests.
Cache Invalidation: How do you know when cached data is stale due to external changes, not just time?
Request Cancellation: How do you cancel in-flight requests when users navigate away or change their mind?
Background Sync: How do you queue operations to execute when connectivity returns?
GraphQL and Real-time Data: How do these patterns adapt to different API paradigms?
These advanced topics go beyond the scope of this article, but you now have the foundation to understand and implement them.
What You’ve Really Learned
You started this series thinking you understood web requests because you could call fetch()
. You didn’t understand web requests—you understood one JavaScript API for making them.
Now you understand:
- The entire network stack from IP addresses to HTTP messages
- How DNS resolution works and why it sometimes fails
- The trade-offs between TCP reliability and UDP speed
- How to build UIs that communicate clearly with users
- How to implement caching that actually improves performance
- How to build resilience into applications that interact with unreliable networks
More importantly, you understand that professional software development isn’t about knowing the latest framework—it’s about understanding the fundamental systems and building on them thoughtfully.
The next time your “simple” API call fails, you won’t be mystified. You’ll understand the dozens of systems involved, the various points of failure, and how to build around them. You’ll debug with purpose instead of panic.
You’ve gone from someone who uses the internet to someone who understands how it works. That’s not just a technical skill—it’s a superpower in a world built on networked systems.
Welcome to the ranks of developers who actually know what’s happening when they press “Send Request.” The rest of us have been waiting for you.