Gaav Tithe Sau · Deep dive
Free for the pilot — and ready to grow.
I promised the foundation that the pilot, about 200 volunteers, would cost nothing to run as long as it stays within the free plan’s limits. That gives them time to judge the system before paying for it. So I treated every byte the database sends out as something to justify. This page covers why, and each optimisation I made.
In 30 seconds
The point
- The problem
- The free server plan allows 5 GB of data out per month. The obvious sync design grows with the square of the number of volunteers.
- What I did
- Designed sync so each volunteer downloads only what they use, built an on-screen meter to measure every action on a real phone, then kept cutting — 17 optimisations in all.
- The result
- Opening the app fell from 7.7 kB and 6 requests to 2.0 kB and 2, measured. Projected use is ~410 kB per volunteer a month.
- What it shows
- I treat constraints as the design brief and check my work with numbers — including when the numbers showed my own plan was wrong.
−74%
data to open the app, measured on a real phone (7.7 kB → 2.0 kB)
6 → 2
server requests to open the app, after merging calls
~12,000
field volunteers the free plan could support, on the ~410 kB/month projection
Why egress?
Egress is the data a database sends out — to phones, to the dashboard, to anyone reading. The project runs on Supabase’s free plan, which caps egress at 5 GB a month and the database at 500 MB. Storing data is cheap; reading it is what runs out first.
The obvious design breaks that budget. If every phone downloads everything new in its village when it opens, each volunteer pays for every colleague’s work. The cost doesn’t grow with the number of volunteers — it grows with the square of it.
One village, one month
10 volunteers, each writing about 1 MB. Each volunteer actually looks at roughly 5% of what colleagues write.
Every optimisation
Seventeen changes in four layers. Each shows what I did, why it works, and why it fits this app. Collapse any you’ve read.
Layer 1 · How data syncs
01 Need-to-know syncVolunteers download what they open — never the whole village’s activity. Egress
What I did
Opening the app makes one request: the volunteer’s profile, assignments, any scheme changes and census figures. There is no village-wide download. Records come down only when a volunteer opens something that needs them.
Why it works
Village-wide sync costs V × (V − 1) × D: every volunteer pays for every colleague’s work. At 10 volunteers that is ~90 MB a month for one village; at 20, ~380 MB. Tying downloads to use keeps it linear: ~0.5 MB and ~1 MB.
Why it fits GTS
A volunteer handles a roughly fixed number of people each month, however big the village gets. So their downloads track their own work, not everyone else’s.
02 A local database on the phoneAnything already seen is shown again for free. Egress + Storage
What I did
An offline-first layer built on drift (SQLite). It stores only the columns screens actually show — about 120 bytes a record — and is capped at a few thousand rows so it can’t fill a cheap phone.
Why it works
Reading from the phone costs no egress. The server only has to answer “has anything changed?”, which is far smaller than “send me everything”.
Why it fits GTS
Offline is read-only, with a bilingual “may be out of date” banner. Writes are disabled, not queued, so there are no conflicting offline edits to untangle — volunteers simply retry when they’re back online.
03 Writes are never read backSaving costs nothing, so a volunteer’s own work is free to view again. Egress
What I did
A save goes to the server first. Once it’s confirmed, the phone caches the row with the timestamp the server sent back, and the screen updates from that cache — no follow-up download.
Why it works
Supabase bills data going out, not data coming in. Measured on the phone: after adding a beneficiary, the home screen showed the new record with zero extra requests.
Why it fits GTS
Most of what a volunteer looks at later is their own work — and it’s already on their phone.
04 One batched freshness checkAsk about a whole screen at once; get back only what changed. Egress
What I did
For the records on screen, the phone sends one list of (record ID, last-updated) pairs, and the server returns only rows that are newer. It started as two checks, one per table; after measuring, I merged them into one request.
Why it works
Re-opening a cached 25-row list went from 3.6 kB to 0.9 kB, measured. When nothing has changed, the reply body is 40 bytes.
Why it fits GTS
Every timestamp the phone sends came from the server, never the phone’s own clock — a phone running fast could otherwise hide a colleague’s edit. So a matching timestamp means the copy is exactly what the server holds, and because an enrollment is usually worked by one volunteer, it was often written by this phone. “Nothing changed” is the normal answer.
05 Refresh re-checks this page onlyPull-to-refresh can be used all day at almost no cost. Egress
What I did
Pulling down to refresh re-runs the freshness check for the rows on screen. There is no way anywhere in the app to download a whole village.
Why it works
Measured: a refresh on a list used to re-download all 25 rows (3.5 kB). It now costs about 1 kB, nearly all of it unavoidable request overhead.
Why it fits GTS
The commissioner’s office asked that volunteers never be given a way to burn through the budget. Refresh still feels responsive — it just can’t be expensive.
06 “Stalled” worked out on the phoneA value the phone can calculate is a value it never downloads. Egress
What I did
A nightly job on the server flags enrollments that have stalled. Instead of downloading those flags, the app recalculates them from data it already holds.
Why it works
The flag follows a fixed rule on two fields — current stage and last update — so the phone reaches the same answer without asking.
Why it fits GTS
Otherwise the nightly job would change many rows at once, and every phone would re-download them the next morning just to learn something it could work out itself.
Layer 2 · Fewer, cheaper requests
07 Measuring on a real phoneThe numbers proved part of my plan wrong — so I changed the plan. Method
What I did
I built an on-screen meter into the app that counts every byte and request by action, then logged sessions on a Samsung phone before and after every change.
Why it works
My design assumed ~250 bytes of overhead per request. The phone showed ~900: the app was speaking HTTP/1.1, so every response repeated its full headers. About 90% of an app open was headers, not data.
Why it fits GTS
That finding redirected the work. Since most responses here are tiny status updates, the cost worth cutting was the number of requests, not their size.
08 One request per screenApp open, home, record detail, search and new enrollment each take one request. Egress
What I did
I combined separate server calls into single requests for the five most common actions — for example, one launch request instead of three, and one call to add an enrollment instead of a duplicate check plus two inserts.
Why it works
Measured: opening the app went from 6 requests and 7.7 kB to 2 requests and 2.0 kB. Adding an enrollment went from 3–4 requests to 1; opening a record from 3 to 1.
Why it fits GTS
The launch requests used to fire all at once, so each paid its own copy of a Cloudflare cookie. One request pays it once.
09 HTTP/2 and a cookie jarHeaders went from ~640 bytes a response to under 100. Egress
What I did
Switched the Android app’s networking to Cronet with HTTP/2, and added a cookie jar so Cloudflare’s bot-check cookie isn’t re-issued on every response.
Why it works
HTTP/2 compresses headers the connection has already carried: warm responses dropped from ~640 B of headers to 71–100 B. Without the jar, a ~320-byte cookie arrived with every response.
Why it fits GTS
Because this app’s responses are so small, headers were most of the bill — so the saving lands exactly where the app spends.
Layer 3 · What gets fetched
10 Scheme list with a change cursorUsed on every screen, downloaded almost never. Egress
What I did
The app stores the scheme list and remembers the newest update time it has seen. Each launch asks only for schemes changed since then, inside the single launch request.
Why it works
Most launches get nothing back. When something does change, only those rows come down.
Why it fits GTS
The placeholder schemes were due to be replaced with real ones. A cache that only fetched new rows would have kept showing old names; tracking update times also catches renames and retired schemes.
11 Search: phone first, then serverCached results appear instantly and are never sent twice. Egress
What I did
Search shows matches from the phone immediately, then asks the server in one request. The server leaves out any result the phone already has, and sends back only the new or changed ones.
Why it works
Search is the most frequent network action. Volunteers mostly look up people they’ve already worked with, so most results cost nothing.
Why it fits GTS
Search covers the whole village, not just the volunteer’s own records, because a beneficiary may approach any Sau Foundation volunteer — who then needs to find their record.
12 Details only when openedRarely used fields stay on the server until someone asks. Egress + Storage
What I did
Portal reference numbers, full status history and the latest note are never stored on the phone. They load in one request when a record opens, and are dropped when it closes.
Why it works
Lists and sync carry only the few fields every screen needs, so downloads and the phone’s database both stay small.
Why it fits GTS
The app targets phones with as little as 2 GB of memory. Keeping the local copy lean matters for the device as much as for the server.
13 Pages, trimmed columns, stable cursorsLong lists arrive 25 rows at a time. Egress
What I did
Lists load 25 rows at a time and request only the columns they display. Deeper pages continue from the last row seen (keyset pagination) rather than “skip the first N rows”.
Why it works
Fewer bytes per row and fewer rows per request. Keyset pages stay fast at any depth and don’t skip or repeat records when data shifts mid-scroll.
Why it fits GTS
Colleagues keep updating enrollments while someone scrolls. A stable cursor means nobody silently disappears from the list they’re working through.
Layer 4 · The database itself
14 Schema shaped for syncEvery sync question becomes a quick index lookup. Server load
What I did
Copied the village ID onto the enrollments table, added an automatic last-updated time to beneficiaries, and indexed both tables by (village, last-updated).
Why it works
“What changed in this village since time T?” no longer needs a join or a full scan. The database jumps straight to the answer, however large the tables grow.
Why it fits GTS
Without a last-updated time on beneficiaries, one volunteer’s edit would have been invisible to every other phone’s copy. The whole sync design depends on it.
15 Storage audits, measured at scaleThe dashboard said 33 MB. The real data was 4.1 MB. Storage
What I did
At ~1,600 enrollments the dashboard reported 33 MB, which suggested a ceiling of ~20,000. I found ~29 MB was fixed system overhead, then built 100,000 enrollments in a scratch schema to measure the real cost.
Why it works
100,000 enrollments fit in about 258–330 MB of the 500 MB plan. Along the way I dropped five redundant indexes, one worth ~15 MB at that scale.
Why it fits GTS
Status history is the one table that grows with every update, so it’s where the storage budget really goes — and where I check first.
16 Pre-counted dashboard numbersLeads get totals, not thousands of raw rows. Egress
What I did
Summary tables keep coverage counts already calculated at village, taluka, district and region level. A trigger updates them in the same transaction as each status change; heavier statistics are rebuilt nightly.
Why it works
Without them, one district dashboard load could scan thousands of records. At full scale that was estimated at ~67 GB of egress a month — more than 13 times the free allowance.
Why it fits GTS
Leads are allowed to see totals only, never individual people’s records — so pre-computed counts are exactly what they need.
17 Store only what’s neededNine fields per person. No Aadhaar, no phone numbers, no addresses. Storage + Privacy
What I did
Each beneficiary record holds just nine fields. The Aadhaar number is hashed on the phone and cleared before anything is sent; only the hash is kept, for spotting duplicates.
Why it works
Less data means less storage — and, more importantly, less that could ever leak.
Why it fits GTS
The goal is tracking whether schemes reach people, not building a registry of them. Aadhaar rules and India’s data-protection law make collecting the minimum the right default.
Beyond the pilot
The free plan covers the pilot. The work above also means that when the foundation grows, it starts paying later, and pays less.
Headroom, measured
At a projected ~410 kB per volunteer per month, the 5 GB allowance covers around 12,000 field volunteers. That covers the field app only. Dashboards, admins and growth come on top, which is why I promised the pilot, not full scale.
Knowing what’s left
The largest remaining cost per volunteer is login-token refreshes, around 132 kB a month. That belongs to the platform, not my design — so I know where the floor is.
Storage checked at scale
I modelled 100,000 enrollments with real column types and every index: about 52–66% of the 500 MB database, depending on how many status updates each one gets.
A plan for outgrowing it
Once the foundation expands past the pilot, the expected next step is Supabase’s paid plan. I also researched self-hosting on a nonprofit cloud grant as a fallback, and set up production under the foundation’s own account so it can be handed over cleanly.