Making web applications available when the network isn't.

Imagine opening an application, waiting several seconds for it to load, and then losing your internet connection.

You refresh the page, and everything disappears.

Perhaps you were trying to submit a form, view some information, or complete an important task. Suddenly, an unstable network has made the entire application unusable.

For many developers, this might seem like an ordinary network failure. But for users who regularly deal with unreliable connectivity, expensive data, and inconsistent network speeds, it is a frustrating part of their everyday experience.

This raises an important engineering question:

How do we build web applications that remain useful when the network becomes unreliable?

I recently delivered a workshop on Building Resilient Apps for Unreliable Networks & Low Bandwidth. The goal was to explore practical techniques developers can use to make web applications more resilient, particularly in environments where internet connectivity cannot always be guaranteed.

In this two-part series, I'll share the concepts, implementation decisions, challenges, and lessons from building a Resilient Task Board.

In this first part, we'll explore browser storage, Service Workers, caching strategies, and how to make an application's interface available offline.

Let's get into it.

1. The Network Is Not Always Reliable

Most modern web applications are designed around an assumption: the network will be available whenever the user needs it.

A typical application might follow this architecture:

User | v Frontend Application | v Backend API | v Database 

When the user performs an action, the frontend sends a request to the backend. The backend processes it, communicates with the database, and returns a response.

This works perfectly when connectivity is stable.

But what happens when:

  • The network becomes extremely slow?
  • A request takes several seconds to complete?
  • The user loses connectivity halfway through an operation?
  • The backend becomes temporarily unavailable?
  • The user refreshes the page while offline?

In many applications, these situations result in loading screens, network errors, or completely unusable interfaces.

The problem isn't necessarily that the backend is poorly implemented. Sometimes, the application simply has no strategy for dealing with an unavailable network.

We need to start thinking beyond the happy path.

Instead of asking only, "How does my application work when the network is available?", we should also ask, "What should happen when it isn't?"

2. Understanding the Browser's Offline Capabilities

Before implementing anything, I wanted to understand what the browser already provides.

There are several browser APIs that can help us build applications capable of working with unreliable connectivity.

The important ones for this project are:

LocalStorage

LocalStorage provides a simple key-value storage mechanism in the browser.

It stores strings, meaning structured objects must be serialized before storage, usually with JSON.stringify().

It is useful for things like:

  • User preferences.
  • Theme settings.
  • Simple application configuration.

However, it is synchronous and doesn't provide the querying, indexing, and transaction capabilities we need for a more structured offline data layer.

IndexedDB

IndexedDB is an asynchronous, browser-based database designed for storing structured data.

Unlike LocalStorage, it allows us to store JavaScript objects, create indexes, and perform database transactions.

For an application like our Task Board, it can store:

  • Previously retrieved tasks.
  • Locally created tasks.
  • Pending operations waiting to synchronize with the backend.

Think of it as a local database that allows the frontend to work with structured application data without constantly depending on the network.

Cache API

The Cache API allows us to store and retrieve HTTP request and response objects.

It is particularly useful for caching:

  • HTML documents.
  • JavaScript and CSS files.
  • Images.
  • Fonts.
  • Selected network responses.

One important distinction: the Cache API and IndexedDB solve different problems.

The Cache API is useful for storing resources and HTTP responses, while IndexedDB is better suited to structured application data.

Service Workers

A Service Worker is a JavaScript file that runs separately from the main browser thread.

It acts as an intermediary between the application, the browser, and the network.

It can intercept network requests, serve cached responses, and provide offline fallbacks.

These four technologies form an important foundation for building resilient web applications.

3. Introducing Service Workers

A Service Worker is one of the most important browser technologies for offline-capable web applications.

It allows us to control how certain network requests are handled.

For example, instead of allowing every request to go directly to the network, we can intercept it and decide whether to:

  • Retrieve a cached response.
  • Fetch a fresh response from the network.
  • Return a cached response when the network fails.
  • Apply a different strategy depending on the resource being requested.

Service Workers have a lifecycle consisting of three major stages:

Registration: The application registers the Service Worker with the browser.

Installation: The browser installs it. This is where we can prepare caches and download essential resources.

Activation: The Service Worker becomes active and can take control of eligible pages.

Here's a basic registration example:

if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker .register("/sw.js") .then((registration) => { console.log("Service Worker registered:", registration.scope); }) .catch((error) => { console.error("Service Worker registration failed:", error); }); }); } 

The browser's support for Service Workers should be checked before attempting registration.

For our project, I created a sw.js file and registered it from the application's entry point.

But registering a Service Worker alone doesn't make an application work offline.

We need to teach it what to cache and how to respond when the network is unavailable.

4. Understanding the Application Shell

One of the first concepts I explored was the Application Shell.

The Application Shell is the collection of essential resources required to load the basic structure and interface of an application.

For a typical frontend application, this might include:

  • HTML document.
  • JavaScript bundles.
  • CSS stylesheets.
  • Essential fonts.
  • Icons and images.

Consider a React application.

The browser first loads the HTML document. That document references JavaScript and CSS files. The JavaScript executes and renders the React interface, while the CSS provides the styling.

What happens if we cache only the HTML document?

The browser may successfully retrieve the HTML while offline, but if the JavaScript bundle is unavailable, React cannot render the application.

The result could be a blank page.

This was an important distinction for me:

Caching the HTML document is not the same as making the entire application shell available offline.

We need to account for all the resources required to render the interface.

5. Pre-caching and Runtime Caching

The next step was understanding two important caching approaches.

Pre-caching

Pre-caching involves downloading important resources and storing them in Cache Storage before the user needs them.

This is commonly done during the Service Worker's installation phase.

For example:

const CACHE_NAME = "resilient-task-board-v1"; const APP_SHELL_ASSETS = [ "/", "/index.html", "/favicon.svg", ]; self.addEventListener("install", (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => { return cache.addAll(APP_SHELL_ASSETS); }) ); }); 

A few things are happening here:

  • caches.open() creates or opens a named cache.
  • cache.addAll() fetches the listed resources and stores their responses.
  • event.waitUntil() keeps the installation event alive until the caching operation settles.

If a required resource fails to download, the installation can fail.

Runtime caching

Runtime caching stores resources as the application requests them.

For example, if the browser requests a JavaScript file and successfully retrieves it from the network, the Service Worker can save a copy in Cache Storage.

On a subsequent request, the cached copy can be returned.

This is particularly useful for resources whose filenames are generated during the build process.

Modern frontend build tools often generate hashed filenames, such as:

/assets/index-a83f21.js /assets/index-b72c41.css 

These filenames can change whenever the application is rebuilt.

That means we need to think carefully about how we discover and cache the actual production assets.

The browser's Cache API remains the same regardless of the framework or build tool. What changes is how we obtain the list of files to cache.

6. Choosing the Right Caching Strategy

Not every resource should be handled in the same way.

Two common strategies are Network-first and Cache-first.

Network-first

With Network-first, the Service Worker attempts to retrieve a fresh response from the network.

If the request fails, it falls back to Cache Storage.

Request | v Try Network | +---- Success ----> Return network response | +---- Failure ----> Return cached response 

This is useful when we want fresh content whenever connectivity is available but still need an offline fallback.

I used this approach for navigation requests.

Cache-first

With Cache-first, the Service Worker checks Cache Storage before attempting a network request.

Request | v Check Cache | +---- Found ----> Return cached response | +---- Missing --> Try Network 

This works well for static resources such as JavaScript, CSS, fonts, and icons.

For our Task Board, I used Cache-first for static resources and Network-first for navigation.

The important lesson is that caching strategies should be selected according to the nature of the resource and how fresh it needs to be.

7. Building the Service Worker for the Task Board

For the Resilient Task Board, I started with a simple Service Worker and gradually improved it.

My implementation introduced:

  • A versioned cache.
  • Pre-caching of stable application entry resources.
  • Cleanup of outdated caches during activation.
  • Navigation fallback to cached HTML.
  • Cache-first handling for static resources.
  • Runtime caching for successfully retrieved static assets.
  • Bypassing API requests so the application's API layer could handle network failures.

The following is a simplified representation of the request-handling logic:

self.addEventListener("fetch", (event) => { const request = event.request; if (request.method !== "GET") { return; } if (request.url.includes("/api/")) { return; } if (request.mode === "navigate") { event.respondWith( fetch(request).catch(async () => { const cachedPage = await caches.match("/index.html"); return cachedPage || Response.error(); }) ); return; } event.respondWith( caches.match(request).then((cachedResponse) => { if (cachedResponse) { return cachedResponse; } return fetch(request); }) ); }); 

This is a simplified excerpt, not the complete production implementation. In the actual project, I also added cache versioning, activation cleanup, and runtime caching of successful static responses.

Notice that API requests are deliberately excluded.

At this stage, the goal was to make the application's interface available offline, not to implement offline API operations. Those are separate concerns that we'll explore in Part 2.

8. A Debugging Lesson: Development Servers Can Mislead You

One of the more interesting challenges I encountered happened during testing.

After successfully caching the application resources, I enabled Offline in Chrome DevTools and refreshed the application.

It failed.

The browser console showed errors similar to:

GET /@vite/client net::ERR_INTERNET_DISCONNECTED GET /src/main.tsx net::ERR_INTERNET_DISCONNECTED GET /@react-refresh net::ERR_INTERNET_DISCONNECTED 

At first, this looked like a Service Worker problem.

But there was another issue.

I was testing through Vite's development server.

Vite injects development-specific resources, including its client and React Refresh functionality. These resources were not part of the production assets I had cached.

I had cached production resources but was testing a development environment.

The solution was to test using the production build:

npm run build npm run preview 

This allowed me to test the actual production output rather than development-specific modules.

After making that adjustment and refining the Service Worker, the application shell successfully loaded offline.

The lesson: Always test offline behaviour against the environment and assets your users will actually receive. A development server can introduce behaviour that doesn't exist in production.

9. What We Have Achieved So Far

At this point, our Task Board can load its basic interface without an internet connection after the necessary resources have been cached.

That's a meaningful improvement, but it isn't the end of the journey.

Imagine opening the Task Board offline.

The interface loads successfully, but where are the tasks?

The backend API is still unavailable.

The Service Worker has helped us cache the application's resources, but we haven't yet provided a reliable local source for the application's actual data.

This brings us to the next challenge.

What's Next?

In Part 2, we'll move beyond making the interface available offline.

We'll explore how IndexedDB can provide local application data, how to handle offline mutations, and how to synchronize pending operations when connectivity returns.

We'll also look at some of the harder problems involved in building resilient applications:

  • Optimistic updates.
  • Offline operation queues.
  • Synchronization and retries.
  • Idempotency.
  • Conflict resolution.

Because an application that merely opens offline isn't necessarily resilient.

The real goal is to build applications that continue to serve users, preserve their actions, and recover gracefully when the network comes back.

This article is based on my workshop, "Building Resilient Apps for Unreliable Networks & Low Bandwidth," and the practical implementation of the Resilient Task Board.

Explore the project: Follow along with the original, non-resilient version of the Resilient Task Board on GitHub and see how we progressively improve it to handle unreliable networks and offline conditions.

Part 2 coming soon.