React for people with weak phones: a lesson from an HN post
Someone posted on Hacker News that they have paid for a young person's education in rural Tanzania for ten years. The post is called "Tell HN: I've been paying for a rural Tanzanian's education for 10 years". It's not a launch. It's not a framework or a release note. Still, it made me open the React project I'm working on right now and ask an uncomfortable question: would this app work in the hands of the person on the other side of that story?
This blog usually covers tech news. This one is about people. But every story like this hides a technical question. When someone leaves a rural area and gets to school, college or a first job, internet access goes through a screen. Almost always it's an entry-level Android, on a flaky network, with a capped data plan. And a lot of those screens today are built with React.
What does the post have to do with React?
Directly, nothing. I didn't see the post mention code anywhere. The link is something else.
Think about the path of someone studying in a rural part of East Africa. Enrollment, course material, exam results, the scholarship, the job opening: more and more of it becomes a web form. And we are the ones who maintain those forms.
We build on a new MacBook, with 500 Mbps fiber and Chrome running on a warm cache. The real user opens the same site on a device with a quarter of your CPU, on 3G that drops in every tunnel, paying by the megabyte.
Your most important user almost never has your machine.
That's what the post reminded me of. Paying for someone's education for ten years is a long, steady commitment. The dev version of that is much smaller: don't make the door too heavy for someone trying to get in.
How heavy a React app is on a cheap phone
Let's get to numbers you can check yourself.
In production, react + react-dom come to about 60 KB gzipped. That sounds small. The problem is that nobody ships just that. Add the router, a form library, a date library, a component kit, an analytics SDK and a support chat widget. It's not rare for a "simple" app to send 500 KB to 1 MB of compressed JavaScript on first load.
With JavaScript, download size is only half the story. The browser still has to decompress, parse, compile and run it. On an entry-level device, that part can take several times longer than on your laptop. I've seen a login screen frozen for seconds on an old Android while the bundle finished running. No errors in the console. Just a user staring at a button that does nothing.
For the user, this shows up in three ways:
- A long blank screen before the first content.
- Taps that don't respond until hydration finishes.
- Data plan spent on code they won't even use on that visit.
How to test your app as if you were there
You don't need to travel or buy an $80 phone to get a feel for it. You need five minutes in DevTools.
- Open Chrome DevTools, Performance tab.
- Under CPU, pick 6x slowdown.
- Under Network, pick Slow 4G or create a slower profile.
- Check Disable cache and reload.
- Record and see how long the main thread stays busy before the page accepts a click.
Do this on your most important screen. It could be login, checkout or the signup form. The first time is a little embarrassing. The second time, you'll want to open a PR.
If you have an old Android in a drawer, even better. Turn on USB debugging, open chrome://inspect and test on the real device. No simulation replaces a real phone heating up in your hand.
What to change in your code today
You don't need to rewrite anything or switch frameworks. Most of the gain comes from boring, cheap fixes.
Load on demand whatever isn't on the first screen. Charts, rich text editors, maps, the settings modal: none of that needs to be in the initial bundle.
import { lazy, Suspense } from 'react'
const ReportChart = lazy(() => import('./ReportChart'))
export function Dashboard() {
return (
<Suspense fallback={<p>Loading report…</p>}>
<ReportChart />
</Suspense>
)
}
Ship less JavaScript, not just smaller JavaScript. If you use Next.js with the App Router, Server Components solve a lot. A course list that only displays data doesn't need to become client code. Keep 'use client' only on the pieces that are actually interactive.
Look at what ended up in the bundle. Run an analyzer once and look for the usual surprises: a whole date library to format one day, icons imported in bulk, a polyfill no current browser needs.
bunx vite-bundle-visualizer
# or, in Next.js
ANALYZE=true bun run build
Set a limit and let CI enforce it. A simple rule keeps the bundle from gaining 20 KB per sprint without anyone noticing. With size-limit, for example:
{
"size-limit": [
{ "path": "dist/assets/index-*.js", "limit": "170 KB" }
]
}
The exact number matters less than having a number. Without a limit, the bundle only grows.
Forms that survive a bad network. Save a draft to localStorage on every change. Someone who fills out a long application and loses everything because 3G dropped on the last field rarely tries again.
const [form, setForm] = useState(() =>
JSON.parse(localStorage.getItem('application') ?? '{}')
)
useEffect(() => {
localStorage.setItem('application', JSON.stringify(form))
}, [form])
That's six lines. For someone on a prepaid plan, those six lines can decide whether the application goes through or not.
"But my users aren't in Tanzania"
That's a fair objection. The answer: they might be closer than you think.
Here in Brazil, lots of people get online only through their phone. A big share of that happens on entry-level devices and prepaid plans. Banking app customers, online students, delivery riders checking their route, people in small towns with one bar of signal. If your product serves "Brazil", it serves these people too, whether you test for them or not.
And there's a nice side effect. Everything that makes the app light on a weak device also makes it faster on your boss's iPhone. Performance for the worst case never hurts the best case. The opposite happens all the time.
My take, without romanticizing it
I won't pretend that optimizing a bundle is charity. It isn't. The person in that post is doing something far bigger than any PR I'll open this year. Comparing the two would be silly, like thinking that removing moment.js from the project makes me a Nobel candidate.
But the post reminded me of something we forget easily. On the other side of the screen is someone with fewer resources than you, trying to finish a task. Sometimes it's an enrollment. Sometimes it's a job application. React isn't to blame for any of this. It just does what we write.
My practical suggestion is small: pick one critical screen in your app, test it this week with a 6x slower CPU and a bad network, and fix the worst thing you find. Just one already helps.
If you want to see how I've been applying this in my own projects, they're here.
LinkedIn summary
A heavy enrollment form can lock out someone with a weak phone. I read a Hacker News post from someone who has paid for a young person's education in rural Tanzania for 10 years. It left me with one question: would my React app work in that kid's hands? We code on new MacBooks with fiber. The real user has an entry-level Android, shaky 3G and a prepaid data plan. Brazil is the same: lots of people only get online through their phone, on a cheap device. It takes 5 minutes in DevTools: 6x CPU slowdown, Slow 4G and cache disabled. Then lazy loading, a bundle limit in CI and drafts saved to localStorage. Pick one critical screen in your app, test it like this this week and fix the worst thing you find. I wrote the step by step on the blog, with code. #React #WebPerformance #Frontend #Accessibility #WebDevelopment