DEV Community

Cover image for Building for Bad Internet: The Architecture Behind Apps That Still Work
Imam Abubakar
Imam Abubakar Subscriber

Posted on

Building for Bad Internet: The Architecture Behind Apps That Still Work

One thing I have learned from building products for users in Africa is that an application can work perfectly on a developer’s laptop and still fail badly in the real world. The engineer tests it with stable Wi-Fi. The API responds in milliseconds. Images load immediately. Forms submit without issues. Everything feels fast. Then the product reaches an actual user. They are connected through mobile data. Their network keeps switching between 3G, 4G, and nothing at all. They open a form, spend several minutes entering information, and tap submit.
The loading indicator appears. The request times out. The application clears the form. Their work is gone. From the developer’s side, this may look like a simple failed API request. From the user’s side, the product cannot be trusted. That difference matters.

I have seen development teams treat poor connectivity as something outside their control. They add an offline banner, increase the request timeout, and assume the problem has been handled. But telling someone that their internet is bad does not help them complete the task. The user already knows the network is bad. What they need is software that expects it.

A merchant should be able to record an order even when the network disappears. A field worker should be able to complete an inspection without waiting for the server. A driver should not lose a completed trip because one request failed. A user filling a long form should never have to start again because the connection dropped at the wrong moment.

This is where the architecture has to change.

Instead of treating the network as something that is always available, the application should treat it as an unreliable service that may become available later. The app should save important actions locally first. It should update the interface immediately. It should queue changes that have not reached the server. It should retry safely when connectivity improves. And when the local device and server disagree, it should have a clear way to resolve the conflict. That is the difference between an application that simply detects poor internet and one that is actually designed for it.

Building for weak connectivity is not a small frontend feature. It affects the database, API design, authentication, file uploads, background jobs, error handling, synchronisation, security, and even the language shown to the user.

This article breaks down how to build that kind of system.

Not just an app that shows an offline message but an app that keeps working.

Weak Internet Is Not the Same as No Internet

One of the first mistakes engineers make is treating connectivity as a boolean value.

Online or offline.

Real network conditions are more complicated. A device may appear online while experiencing:

  • Very high latency
  • Packet loss
  • DNS failures
  • An unstable mobile connection
  • A captive Wi-Fi portal
  • A connection that can receive data but struggles to upload it
  • A request that begins successfully but fails halfway
  • A network change from Wi-Fi to mobile data
  • A connection too slow for the current payload

This means checking whether the device is “online” is not enough. The application must assume that every network request can fail, time out, complete twice, or finish after the user has already left the screen. That assumption should influence the entire system.

The Main Principle: Local First, Network Second

In a traditional request-first application, the server is treated as the immediate source of truth. The user performs an action. The app sends it to the server. The interface waits. The server responds. Only then does the interface update.

An offline-first application reverses this flow. The user performs an action. The app validates and stores the change locally. The interface updates immediately. The app adds a mutation to a local queue. A sync engine sends that mutation to the server when the network is usable. The server confirms or rejects it. The local record is updated with the result. The network is still important, but it is no longer blocking every user action.

A Basic Offline-First Architecture

A practical implementation can be separated into six parts:

  1. User interface
  2. Local database
  3. Repository or data-access layer
  4. Durable mutation queue
  5. Sync engine
  6. Remote API

The flow looks like this:

User action
    ↓
Local validation
    ↓
Local database transaction
    ↓
UI updates from local database
    ↓
Mutation added to outbox
    ↓
Sync engine processes outbox
    ↓
Remote API
    ↓
Local record updated with server result
Enter fullscreen mode Exit fullscreen mode

The interface should read from the local database rather than directly from API responses.

This gives the application one consistent data flow, whether the network is available or not.

Choosing the Local Database

For native mobile applications, SQLite is usually a strong starting point.

Depending on the framework, this could be accessed through:

  • Room on Android
  • Core Data or SQLite on iOS
  • Expo SQLite
  • WatermelonDB
  • Realm
  • Another durable database built for the chosen platform

For web applications, IndexedDB is more suitable for structured offline data than localStorage.

localStorage is synchronous, limited, and designed mainly for small key-value data.

A proper local database should support:

  • Transactions
  • Indexes
  • Schema migrations
  • Structured queries
  • Durable writes
  • Relationships or references
  • Enough storage for the application’s offline scope

The database should not be treated as a temporary cache.

It is part of the application’s data architecture.

Model the Synchronisation State

Every locally changeable record should have enough metadata to explain its state.

A simplified record might look like this:

type LocalOrder = {
  id: string;
  serverId?: string;
  customerId: string;
  total: number;
  status: "draft" | "submitted" | "completed";
  syncStatus: "pending" | "syncing" | "synced" | "failed" | "conflict";
  localVersion: number;
  serverVersion?: number;
  updatedAt: string;
  lastSyncError?: string;
};
Enter fullscreen mode Exit fullscreen mode

The application should not hide failed synchronisation.

A record can exist locally while still waiting to reach the server.

That is different from a record that has been accepted by the server.

The interface should make this state clear without making the application frustrating to use.

Use a Durable Outbox

A sync queue should not live only in application memory.

If the app closes, crashes, or the operating system kills the process, an in-memory queue disappears.

Instead, store pending operations in a durable outbox table.

type OutboxItem = {
  operationId: string;
  entityType: "order" | "customer" | "inventory";
  entityId: string;
  operation: "create" | "update" | "delete";
  payload: string;
  attemptCount: number;
  nextAttemptAt: string;
  createdAt: string;
  status: "pending" | "processing" | "failed";
};
Enter fullscreen mode Exit fullscreen mode

When the user creates an order, the local order and its outbox operation should ideally be written in the same database transaction.

await database.transaction(async tx => {
  await tx.orders.insert(localOrder);

  await tx.outbox.insert({
    operationId: crypto.randomUUID(),
    entityType: "order",
    entityId: localOrder.id,
    operation: "create",
    payload: JSON.stringify(localOrder),
    attemptCount: 0,
    nextAttemptAt: new Date().toISOString(),
    createdAt: new Date().toISOString(),
    status: "pending",
  });
});
Enter fullscreen mode Exit fullscreen mode

Using one transaction prevents a situation where the local record is saved but its pending sync operation is lost.

Make Every Mutation Idempotent

Weak networks create duplicate requests.

A client may send a request successfully but lose the response.

From the client’s perspective, the request failed.

It retries.

Without protection, the server may create the same order twice.

Every important mutation should include an idempotency key.

POST /orders
Idempotency-Key: 76e19da0-72f4-4b27-b7f6-80a14c450a91
Enter fullscreen mode Exit fullscreen mode

The server stores the key and the original result.

When it receives the same key again, it returns the previous result instead of repeating the operation.

A basic server-side table might contain:

idempotency_key
user_id
request_hash
response_status
response_body
created_at
expires_at
Enter fullscreen mode Exit fullscreen mode

The key should be scoped to the authenticated account and, where necessary, checked against the request payload.

Idempotency is especially important for:

  • Payments
  • Orders
  • Inventory deductions
  • Messages
  • Bookings
  • Invoice creation
  • Any action with financial or operational consequences

Build a Real Sync Engine

A sync engine is more than a function that runs when the app detects a connection.

It should decide:

  • When synchronisation starts
  • Which operations run first
  • How many requests run concurrently
  • How failed requests are retried
  • What happens after authentication expires
  • How conflicts are handled
  • Whether large transfers should wait for Wi-Fi
  • How the app recovers after a crash

Useful sync triggers include:

  • Application launch
  • Application returning to the foreground
  • A successful login or token refresh
  • A network connectivity change
  • A user pressing “Sync now”
  • A platform-supported background task
  • A scheduled periodic check

Do not depend on only one trigger.

Mobile operating systems and browsers can delay or restrict background work.

The application should always be able to resume its queue the next time it becomes active.

Retry With Exponential Backoff and Jitter

Immediately retrying a failed request in a tight loop wastes battery, data, server capacity, and tokens on the user’s mobile plan.

Use exponential backoff.

function getRetryDelay(attempt: number): number {
  const baseDelay = 1_000;
  const maximumDelay = 15 * 60 * 1_000;
  const exponentialDelay = baseDelay * 2 ** attempt;
  const jitter = Math.random() * 1_000;

  return Math.min(exponentialDelay + jitter, maximumDelay);
}
Enter fullscreen mode Exit fullscreen mode

A possible retry sequence could be approximately:

1 second
2 seconds
4 seconds
8 seconds
16 seconds
...
Up to a maximum delay
Enter fullscreen mode Exit fullscreen mode

Jitter prevents thousands of disconnected clients from reconnecting to the server at exactly the same moment.

Not every error should be retried.

Retry:

  • Timeouts
  • Temporary network failures
  • HTTP 429 responses
  • Most HTTP 5xx responses

Do not continuously retry:

  • Invalid input
  • Permission failures
  • A deleted account
  • Unsupported application versions
  • Permanent business-rule failures

These should be surfaced for user action or moved to a failed state.

Push Changes and Pull Changes Separately

Synchronisation usually has two directions.

Push

Send local mutations to the server.

Pull

Download remote changes made by other devices, administrators, or server processes.

These operations should be designed separately.

A failed upload should not always prevent the application from downloading newer data.

For pulling changes, avoid downloading the entire dataset every time.

Use cursor-based delta synchronisation.

GET /sync/orders?cursor=eyJ2ZXJzaW9uIjozNDU2fQ
Enter fullscreen mode Exit fullscreen mode

A possible response:

{
  "changes": [
    {
      "id": "order_102",
      "version": 8,
      "operation": "update",
      "data": {
        "status": "completed"
      }
    }
  ],
  "nextCursor": "eyJ2ZXJzaW9uIjozNDYwfQ",
  "hasMore": false
}
Enter fullscreen mode Exit fullscreen mode

The client stores the new cursor only after applying the entire response successfully.

If processing fails halfway, it can safely retry from the previous checkpoint.

This is more reliable than depending only on timestamps.

Device clocks can be wrong, and records with identical timestamps may be skipped.

Conflict Resolution Is a Product Decision

A conflict happens when the same data changes in more than one place before synchronisation.

For example:

  1. A field worker changes a customer’s address offline.
  2. An administrator changes the same address from the dashboard.
  3. The field worker reconnects and uploads the older local change.

The correct resolution depends on the business domain.

Common strategies include:

Server Wins

The server rejects stale local changes.

Useful when central control is more important than local edits.

Client Wins

The latest local submission replaces the server value.

Simple, but dangerous for shared or sensitive data.

Last Write Wins

Whichever change has the newest timestamp wins.

Easy to implement, but unreliable when clocks differ and destructive when two valid edits affect different fields.

Version-Based Rejection

Every record has a version number.

The client sends the version it edited.

{
  "id": "customer_10",
  "expectedVersion": 5,
  "changes": {
    "phone": "+234..."
  }
}
Enter fullscreen mode Exit fullscreen mode

If the server is already on version 6, it returns a conflict.

409 Conflict
Enter fullscreen mode Exit fullscreen mode

The client can then fetch the latest record and either merge automatically or ask the user to choose.

Field-Level Merge

Changes to different fields are combined.

For example, one user updates the phone number while another updates the address.

This preserves more work than replacing the entire record.

Domain-Specific Rules

Inventory, payments, bookings, and collaborative documents require different policies.

For example, two offline inventory deductions should not simply overwrite each other.

They may need to be represented as separate inventory movements and calculated by the server.

There is no universal conflict strategy.

Conflict resolution belongs in the product specification, not just the database layer.

Optimise the API for Expensive Networks

An offline-first client can still perform badly if the API transfers too much data.

Use:

  • Cursor-based pagination
  • Delta responses
  • Response compression
  • Sparse field selection
  • Thumbnail versions of images
  • Conditional requests
  • Sensible timeouts
  • Request batching where appropriate
  • Compact response structures
  • Server-side filtering

Avoid returning a complete object graph when the screen needs only four fields.

For example:

GET /products?fields=id,name,price,thumbnail&updatedAfter=...
Enter fullscreen mode Exit fullscreen mode

Also avoid many small sequential requests.

A screen that needs 15 requests will perform badly on a high-latency connection even when every response is small.

Sometimes one properly designed aggregate endpoint is better.

Cache Assets and Data Differently

Application assets and business data have different caching requirements.

Static assets include:

  • JavaScript bundles
  • CSS
  • Fonts
  • Icons
  • Application shell files

Business data includes:

  • Orders
  • Customers
  • Products
  • Messages
  • Account information

For web applications, service workers can intercept requests and apply different strategies.

Cache First

Suitable for versioned static assets.

Return the cached asset immediately and fetch from the network only when it is absent.

Network First With a Timeout

Suitable for data that should be fresh but can fall back to a cached version.

Try the network for a limited period.

If it is too slow or fails, return cached data.

Stale While Revalidate

Return cached data immediately and update it in the background.

Useful for content where slightly older data is acceptable.

Sensitive or highly dynamic information should not be cached without a clear expiry and security model.

Treat Media as a Separate Problem

Images, videos, voice notes, and documents are often responsible for most network usage.

Do not process them like small JSON requests.

A stronger media flow is:

  1. Save the media locally.
  2. Create a local preview or thumbnail.
  3. Compress where appropriate.
  4. Add an upload operation to the queue.
  5. Upload in chunks or through a resumable protocol.
  6. Store upload progress.
  7. Resume after interruption.
  8. Replace the local reference with the remote URL after completion.

Allow users to choose whether large files can upload over mobile data.

Where possible, avoid forcing users to redownload media they already have locally.

Authentication Must Survive Temporary Disconnection

Offline access introduces security questions.

The application may need to work after the access token has expired but before it can contact the authentication server.

Possible rules include:

  • Allow access only to previously authorised local data.
  • Require device authentication for sensitive offline data.
  • Encrypt local databases or sensitive fields.
  • Prevent high-risk actions from being finalised offline.
  • Queue authorised low-risk actions until the session can be verified.
  • Purge local data on logout.
  • Separate each account’s local storage.
  • Expire sensitive cached data after a defined period.

Do not store access tokens or sensitive information in plain text.

Offline support should not turn a stolen device into a full copy of the user’s account.

Do Not Hide Synchronisation From Users Completely

The application should feel simple, but users still need to understand whether their work is safe.

Useful states include:

  • Saved on this device
  • Waiting to sync
  • Syncing
  • Synced
  • Sync failed
  • Needs attention
  • Conflict detected

Avoid vague messages such as “Something went wrong.”

Tell the user whether their work remains stored locally and what will happen next.

For example:

Saved on this device. We will upload it when your connection improves.

That is much better than making the user submit the same form repeatedly.

Observe the Sync System

Normal API monitoring is not enough.

Track offline-specific metrics such as:

  • Percentage of mutations synced successfully
  • Average time from local creation to server confirmation
  • Age of the oldest pending operation
  • Number of operations in each device’s queue
  • Retry counts
  • Conflict frequency
  • Permanent failure rate
  • Average request and response size
  • Upload resumption rate
  • Synchronisation duration
  • Percentage of users operating with stale data

Do not log sensitive payloads just because synchronisation is difficult to debug.

Use operation IDs, entity IDs, error categories, versions, and timestamps instead.

Test Like the Network Is Hostile

Testing only on office Wi-Fi gives false confidence.

Test scenarios should include:

  • Complete disconnection
  • Very high latency
  • Packet loss
  • Slow uploads
  • Slow downloads
  • A connection dropping during a request
  • A connection dropping during a file upload
  • The app being killed during synchronisation
  • The device restarting with pending operations
  • Duplicate requests
  • Out-of-order responses
  • Expired authentication
  • HTTP 429 and 5xx responses
  • Multiple devices changing the same record
  • Incorrect device time
  • Local database migration with pending operations
  • A user logging out before synchronisation completes

The most dangerous failures often happen between steps.

For example, the server saves the order successfully, but the connection drops before the client receives the response.

That is why idempotency, local state, and recovery logic matter.

A Practical Sync Loop

A simplified sync worker might look like this:

async function runSync(): Promise<void> {
  if (syncLock.isLocked()) return;

  await syncLock.run(async () => {
    await refreshAuthenticationIfPossible();

    const operations = await outbox.getReadyOperations({
      limit: 25,
      now: new Date(),
    });

    for (const operation of operations) {
      try {
        await outbox.markProcessing(operation.operationId);

        const response = await api.sendMutation({
          idempotencyKey: operation.operationId,
          entityType: operation.entityType,
          operation: operation.operation,
          payload: JSON.parse(operation.payload),
        });

        await database.transaction(async tx => {
          await tx.entities.applyServerResult(response);
          await tx.outbox.remove(operation.operationId);
        });
      } catch (error) {
        if (isConflict(error)) {
          await outbox.markConflict(operation.operationId, error);
          continue;
        }

        if (isPermanentFailure(error)) {
          await outbox.markFailed(operation.operationId, error);
          continue;
        }

        await outbox.scheduleRetry(
          operation.operationId,
          getRetryDelay(operation.attemptCount),
        );
      }
    }

    await pullRemoteChanges();
  });
}
Enter fullscreen mode Exit fullscreen mode

A production implementation needs stronger locking, authentication handling, cancellation, observability, batching, and platform-specific background execution.

But the core pattern remains the same:

Persist first.

Queue the operation.

Synchronise safely.

Recover from interruption.

When Offline-First May Be the Wrong Choice

Not every product needs full offline mutation support.

It adds complexity to:

  • Data models
  • Testing
  • Conflict handling
  • Authentication
  • Storage
  • Security
  • Product design
  • Observability

A marketing website may need only cached assets.

A banking operation may allow the user to prepare a transaction offline but require connectivity before final submission.

A collaborative editor may need a much more advanced merge model than a basic outbox queue.

Choose the offline scope based on the user’s most important workflow.

Ask:

What must the user still be able to do when the network fails?

Start there.

Final Thought

Building for weak internet is not mainly about detecting that the user is offline. It is about removing the network from the critical path of every action that does not truly require it. The strongest systems assume interruption. They save work before sending it. They retry safely. They avoid duplicate effects. They download only what changed. They explain synchronisation state clearly. And they recover without forcing the user to start again.

An offline banner tells the user there is a problem.
An offline-first architecture allows them to keep working.

I hope you've learnt something new.
Sayonara ✌🏽

Top comments (0)