Skip to content
article

Building APIs for users on 2G, prepaid data and phones from 2017: 20 things I learned the expensive way (1/20)

Payload size is a pricing decision. A thread from six years of payment backends.

Achieng Owino@achieng_devrel

July 10, 2026 · 4 min read

Contents(1)

1/ Your users pay for every megabyte. Prepaid, in a currency that may have lost value this year. Every KB your API sends is money out of their pocket. Start there and most of this thread follows.

2/ Test like your users live. Chrome's "Slow 3G" preset is a holiday. On a Linux box between your test phone and the internet:

sudo tc qdisc add dev eth0 root netem delay 400ms 100ms loss 2% rate 400kbit
# remove it again
sudo tc qdisc del dev eth0 root

3/ Compress your JSON. I have seen production APIs that gzip the marketing site and send JSON raw. In nginx:

gzip on;
gzip_types application/json text/css application/javascript;
gzip_min_length 512;
gzip_comp_level 5;

Then check the response really has Content-Encoding. Do not assume.

4/ Let clients ask for less. GET /v1/transactions?fields=id,amount,status,created_at. Our transactions list went from 14KB to 1.1KB per page. Same screen.

5/ Cursor pagination, small default pages. Default 20, max 100. Offset pagination breaks when new transactions arrive while the user scrolls, and they always arrive.

6/ ETags. For a balance screen that people refresh nervously ten times a day, this is most of your traffic:

GET /v1/balance
If-None-Match: "a1b2c3"

HTTP/1.1 304 Not Modified

Zero bytes of body. The balance did not change. Nothing to send.

7/ Idempotency keys on everything that moves money. On a bad network the request reaches you, your response does not reach them, they tap again. Without a key you charge them twice.

POST /v1/payments
Idempotency-Key: 6f1c8e2a-4b1d-4f7a-9c3e-2d5b8a7f1e90

Store the key and the response, return the stored response on a repeat. Keep them for a day or three.

8/ Do not hold requests open. If you are waiting on a bank or a telco, return 202 Accepted with a status URL in a few hundred milliseconds instead of hanging for 60 seconds while the user's connection drops.

9/ Poll that status URL with backoff: 2s, 4s, 8s, then every 30s. Not every 500ms from a phone with 3% battery.

10/ One screen, one request. If your home screen makes 11 calls and the round trip is 800ms, users wait many seconds before anything appears, even with parallel requests. Build one endpoint for that screen. Call it a BFF, call it whatever, just build it.

11/ Reuse connections. A fresh TCP plus TLS 1.2 handshake is three round trips before the first byte. At 800ms RTT that is over two seconds of nothing. TLS 1.3, keep-alive, HTTP/2, session resumption. Boring settings, huge wins.

12/ Never send full size images in a list. A 48px avatar is 48px. Resize on the server, serve WebP, and put the size in the URL so caches work.

13/ SMS for the critical things. "Payment received" and OTPs should also go by SMS. When data dies, SMS very often still arrives.

14/ If your users have feature phones, your API is a USSD menu. Keep it shallow. USSD sessions time out, and every level of menu is a chance to lose them.

15/ Offline queues on the client, with the idempotency keys from 7/, so syncing twice is harmless.

16/ Error messages must say whether money moved. "Network error. Your payment was NOT sent. Try again." is worth more than any retry logic. "Error 500" is how you get a call centre full of crying people.

17/ Do not ship 2MB of JavaScript to show someone their balance.

18/ Cache public data. Exchange rates, product lists, bank lists: Cache-Control: public, max-age=300. Your bank list did not change in the last five minutes.

19/ Measure by country and by network type, p95 and p99, not a global average. Your global p50 is 180ms because half your requests come from your own office in Westlands.

20/ Test on a cheap phone. A real one. A Tecno, an Infinix, an itel, from the market, not the emulator. Sources and further reading: MDN's guide to HTTP caching, RFC 9110 for conditional requests and ETags, the IETF draft "The Idempotency-Key HTTP Header Field", and man tc-netem. Sawa, that is the thread. What did I miss?

Written by

Achieng Owino

Developer advocate, Nairobi. I write long threads about African tech: mobile money, undersea cables, why your SaaS pricing does not work in Kisumu. Formerly backend at a payments company. Opinions mine, data cited. Karibu.

Get the best of Dynamo Geeks weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

16

Sign in to join the conversation.

Sign in to comment
Oluwatobi Adeyemi@tobi_freelance
Point 16!!! "Say whether money moved." Nigerian bank apps should frame this on the wall. "Transaction failed" and the money has left your account, then you call customer care for 3 days
7 net votes
Nadia Benali@nullbyte_nadia
Very good thread. Security footnote on 7/ : scope the idempotency key per account (or per API key). If keys are global, someone who sees or guesses a key can get another user's stored response replayed to them. Seen it in a bug bounty, it was not pretty.
5 net votes
Achieng Owino@achieng_devrel
Excellent addition, thank you Nadia. Key plus account id as the lookup, always. Adding it to my notes for part 2.
8 net votes
Graham Whitfield@greybeard_graham
On 2/, I would add "distribution normal" to the delay. Real networks are not uniformly bad, they are bad in lumps. Cheers, G.
5 net votes
Achieng Owino@achieng_devrel
Yes, and a correlation value on the loss for bursty loss. Real 2G does not drop packets politely one at a time.
5 net votes
Hamza Butt@hamza_flutter
From Lahore, point 21 for firewall season: assume random TLS connections just hang for 30 seconds for no reason. Short connect timeout, longer read timeout, retry with backoff. Otherwise app looks frozen
8 net votes
Dana Kowalski@dana_ml
Excellent. A small addition to 19/: also count the requests that never finished. Client-side timeouts often never show up in server metrics at all, so your p99 looks healthy while users stare at a spinner.
12 net votes
Rafiul Islam@rafi_dhaka
Point 10, the 11 calls on the home screen. Brother, our Norwegian client's app does exactly this, and they asked us why the Bangladeshi testers always complain. Sending them this thread
10 net votes
Alasdair Reid@onprem_or_die
or just build a website in html like 2004 and none of this is a problem
3 net votes
Achieng Owino@achieng_devrel
Honestly? Often yes. A server-rendered page with a form is the most low-bandwidth-friendly architecture ever invented. I am not joking.
5 net votes
Alasdair Reid@onprem_or_die
didnae expect to win that one. aye. cheers
4 net votes
Nguyễn Minh Hiếu@homelab_hieu
Saving this. I only use ETag for static files, never for API. Will try on our dashboard API this weekend, hope it help :)
8 net votes
Trey Robinson@techwithtrey
Point 20 is why I keep a $90 Android in my review drawer. Every app feels different on it. Real talk, some "fast" apps take 9 seconds to open
5 net votes
Oleksii Bondarenko@oleksii_kyiv
During blackouts all Kyiv is on mobile data, 500 people on one tower. Your thread is our evening, every evening
11 net votes
Karl Lindqvist@kernel_karl
Point 17 should be printed and stapled to every frontend team's door.
5 net votes
Elin Sjöberg@elin_pm
Late, but sharing this with our product team. Point 16 is a product decision, not an engineering one, and most teams never make it on purpose.
7 net votes