Skip to content
Sudip KC writing notes in a journal at a desk
SK.
← All articles

Nepal Tech · · 6 min read

Designing Apps for Slow Internet Connections

  • Nepal
  • Performance
  • PWA
  • UI/UX
  • Architecture

Most apps are built on fibre and tested on office Wi-Fi. Then they meet a farmer on one bar of mobile data, a waiter in a basement restaurant, or a commuter on a bus between towns. That's where the real design work starts.

I live in Pokhara, and I build for people across Nepal. Connections here can be fast in one room and gone in the next. Mobile data costs money, and people notice when an app eats it. So "works on slow networks" isn't an edge case on my projects. It's a core requirement, and it changes decisions from the first sketch.

Slow is not the same as offline

The first thing I had to unlearn was treating the network as a yes-or-no switch. In practice there are several conditions, and each needs handling:

  • Fast and stable: the case we all test.
  • Slow but working: requests succeed, but take five to fifteen seconds.
  • Flaky: some requests succeed, some time out, with no pattern.
  • Captive or broken: the phone says it's connected, but nothing loads.
  • Offline: clearly no connection.

Offline is actually the easiest to design for, because the browser tells you. The middle cases are harder. navigator.onLine will happily report true while every request hangs. That's why I design around request outcomes, not around the connection indicator.

Send less, and send it in the right order

The fastest request is the one you don't make. Before optimising anything clever, I cut weight:

  • Images: serve sized, compressed images. In Next.js, next/image with correct sizes does most of this. A 2MB hero photo on a phone is a design bug.
  • JavaScript: every library has to justify itself. Date pickers, chart libraries and icon packs are the usual suspects.
  • Fonts: one family, two weights, self-hosted. Use a fallback that looks close so text is readable before the font arrives.
  • API payloads: return what the screen needs, not the whole model. A list view doesn't need every nested relation.
  • Pagination: never load 500 rows to show 20.

Order matters as much as size. The text a user came to read should arrive before the decorative video. Server rendering helps a lot here, because HTML with real content can show up before any JavaScript runs.

On a slow network, every kilobyte is a design decision whether you treat it as one or not.

Make every wait visible and honest

When a request takes ten seconds, the worst outcome is a frozen screen. People tap again, refresh, or leave. I make sure each wait has visible feedback: a skeleton for content, a busy state on the button that was pressed, and a message if the wait goes on too long.

I covered the patterns for this in Handling Loading, Error and Empty States Properly. On slow networks, one addition matters: a second stage of feedback. If nothing has happened after about eight seconds, change the message to "Still loading, your connection seems slow." It tells the user the app hasn't crashed.

Timeouts and retries you control

Browsers will wait a very long time for a hanging request. I'd rather decide myself when to give up and offer a retry. A small wrapper around fetch with AbortSignal.timeout does the job:

export async function fetchWithTimeout(
  input: RequestInfo,
  init: RequestInit = {},
  timeoutMs = 15000,
) {
  const signal = init.signal
    ? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)])
    : AbortSignal.timeout(timeoutMs);

  try {
    return await fetch(input, { ...init, signal });
  } catch (error) {
    if (error instanceof DOMException && error.name === "TimeoutError") {
      throw new Error("The request took too long. Check your connection and try again.");
    }
    throw error;
  }
}

With TanStack Query, I set retries with backoff for reads, and no automatic retries for writes unless the endpoint is idempotent. Retrying a "place order" request blindly is how a restaurant ends up with two identical kitchen tickets.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
      retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 8000),
      staleTime: 60_000,
    },
    mutations: { retry: 0 },
  },
});

A generous staleTime is underrated here. If the data from a minute ago is fine, don't refetch it every time a component mounts.

Keep what you already have

The most useful thing an app can do on a bad connection is not throw away good data. If a list loaded five minutes ago, show it, mark it as possibly outdated, and refresh in the background. Wiping the screen to show a spinner because a refetch started is a small cruelty.

On the web, a service worker lets you go further. On my portfolio I cache pages network-first with an offline fallback, and static assets with stale-while-revalidate, so a repeat visit works even when the network doesn't. The details are in I Turned My Portfolio Into a PWA — Here's How.

Make writes survive the network

Reads are the easy half. Writes are where slow networks hurt real work. Say a waiter taps "send to kitchen" and the request hangs. Did it go through? Should they tap again?

For NovaRestro's offline waiter ordering, the core idea is to separate the user's action from the network request:

  1. Save the order locally first and show it as "pending" right away.
  2. Send it with a client-generated ID, so the server can ignore duplicates.
  3. Retry in the background when the connection comes back.
  4. Mark it "sent" only when the server confirms.

The waiter keeps working, and the UI is honest about what has and hasn't reached the kitchen. The idempotency key is the piece people skip, and it's the piece that prevents duplicate orders.

Design choices, not just engineering

A lot of slow-network work happens in Figma, before any code:

  • Fewer screens per task. Each navigation is another round trip. A form split over five steps costs five waits.
  • Text before media. Layouts should make sense with images still loading.
  • Large, clear tap targets on loading and retry buttons, because people tap more when they're anxious.
  • Avoid autoplay video on mobile layouts unless it's essential.
  • Status messages in the user's language. On the agriculture platform, loading and error messages had to read naturally in Nepali, not like a translated stack trace.

That platform was the most demanding project for this. I wrote about it in Building an Agriculture Knowledge Platform for Nepal.

How I test it

I don't trust myself to remember slow networks while working on a fast one, so I build testing into the routine:

  • Chrome DevTools network throttling on a slow 3G or custom profile while building any data-heavy screen.
  • Testing on a real mid-range Android phone over mobile data, not just the laptop.
  • Switching to airplane mode mid-action to see what breaks: during a form submit, during navigation, during a file upload.
  • Checking the total transfer size of key pages in the Network tab, and treating a sudden jump as a regression.
  • Lighthouse for a baseline, while remembering it's a lab test and not a phone in a village.

The short version

Designing for slow internet is mostly respect: for people's time, their data costs, and the work they're trying to get done. Send less. Show progress. Control your timeouts. Keep good data on screen. Make writes safe to retry. And test on the network your users actually have, not the one in your office.

If you build for Nepal, or anywhere connections are uneven, this isn't optimisation you do at the end. It's how the product should be shaped from the start. I made a broader case for that kind of local thinking in Building Software for Nepal: Problems Developers Should Be Solving.