Case study · SaveSync
SaveSync
The bookmark app that kept forgetting where it put things, and five months of making it stop.
The account had 1,630 saves. Opening its collections took 13.7 seconds. For about seven and a half of those, Postgres was reading 60,735 rows to find the 1,331 that belonged to it. The other 59,404 belonged to everyone else.
Nobody had filed a bug about it. People don’t file bugs about slow. They form an opinion that the app is unreliable, and then they stop reaching for it. That opinion never shows up in an error log, which is what makes it expensive.
SaveSync saves a link from anywhere, your phone’s share sheet, a browser extension, the web app, and finds it again by what it was about rather than what it was called. It is one small team, one Postgres database and four ways in. Making a bookmark manager is easy. There are dozens. The hard part is making the one people keep.
I joined in May 2026 as product engineer, on a small team led by Swaroop, who owns SaveSync. Since then I have opened 264 pull requests and 241 have merged. I built most of the mobile app and did the performance work while the library grew from about 500 bookmarks to 61,145. Growth like that belongs to the whole team. My part was making sure the app held up under it.
I should say up front: I am not a Postgres expert. I can navigate Supabase well enough, and I read the trace before I guess. That turned out to be most of the job.
The owner wanted Kotlin
When I arrived, switching tabs in the Android app lagged, and the owner was thinking about rewriting it natively in Kotlin. That is a reasonable thought. Native apps are fast. It would also have meant two codebases, an iOS app that still didn’t exist, and every bug fixed twice.
We decided to fix the React Native app instead and ship iOS from the same code. Then I had to be right about it.
The lag was one line. Every time you opened a folder, the app asked Supabase who you were, waited for the answer, and only then looked in its own cache for the saves it already had. Even a warm cache flashed a loading screen, because the question went to the network first. The app already kept your user id on the device. It now reads that, shows what it has, and asks the server afterwards (#61). It also quietly warms the first page of up to eight folders in the background, after the screen has finished drawing, so the first open is a cache hit too.
The fix for “React Native is slow” was to stop asking the server who you are before showing you what you already had.
The iOS app now runs on the same code, with a home-screen widget, an iPad layout with a sidebar that stays put, and an optional Face ID lock (#289, #290, #294).
Early on I also did some housekeeping on how the project kept its credentials and build scripts. Not glamorous. It’s the kind of work nobody notices until it’s missing.
Four bugs that were one bug
In August, four bug reports arrived within a day of each other. The edit card and the swipe-to-edit card behaved differently. One of two menus used the wrong font. The right-swipe dialog should be the real one. Sharing a link took too long.
Read one at a time, they are four small tickets. Read together, they say the same thing four ways: the app had grown two surfaces for the same job, and each pair had drifted apart. Two card editors. Two menu sheets. Two ways to save. Each pair started as a copy of the other and then got its fixes separately. Fixing the tickets one by one would have meant fixing every future bug twice, once per copy, forever. So they became a five-PR stack: one editor, one menu row, one save path, and collections that tell the truth (#167, #172 to #175).
The worst one sounded like a UI problem. “I put a link in three collections. It’s only in two. The last one is missing. No, sometimes the first.”
It was a database trigger. SaveSync stored the same fact twice: an old column holding one collection per save, and a newer table holding many. Triggers kept the two in step. Every write path saved the memberships, then updated the old column “for backwards compatibility”. That second write fired the other trigger, which deleted the membership for the collection it was moving away from: one the user had just picked. No error. No log line. The save worked. It also deleted something.
I had shipped the same bug myself, one PR earlier. Swaroop, who owns SaveSync, was working on the same area in parallel and wrote the trigger’s behaviour down in his PR description. That is how I found mine. So I went through his two branches and mine feature by feature, and posted on each PR which version won and why, including the ones where his did. His menu switched panels before waiting for the network; mine waited, so a tap looked dropped and people tapped twice. His was better. Mine measured the keyboard instead of guessing a scroll offset. Absorbing someone’s work without saying what happened to it is how people stop contributing.
Two smaller decisions from the same stack have stuck with me.
Errors used to read Could not save: API Error: 4. The message was built from the HTTP status and then cut at 30 characters, so the one useful part was the part that got cut. Now it says Not saved, then why: no connection, you’ve hit your save limit, SaveSync is having trouble. “Failed” describes what the software did. “Not saved” describes your data, which is the thing you actually care about.
And the “AI tag suggestions” we were asked to ship took the longest words in the page title. On real titles it suggested introducing, guide, 2026 and everything. A missing suggestion costs a few seconds of typing. A wrong one leaves a junk tag in your account forever. What shipped suggests only tags you already own, plus a short list of sites whose subject isn’t in dispute. We dropped the word “AI” from it, because a stop-word list behind a sparkle icon is a promise the code can’t keep.
Seventy folders called memes
In September I found a user with 70 folders called memes. All from Instagram, all made in 391 seconds. Sixty-nine of them held one save each.
Four routes in the code could create a folder. Two of them looked the folder up by name with a call that errors if it finds more than one match. Both threw that error away and took the “not found” branch, which made a new folder. So the second copy caused a third, the third caused a fourth, and an Instagram sync of 69 posts made 70 folders. It doesn’t drip. It compounds.
The fix put all four routes through one helper (#350). It never uses a lookup that can fail on duplicates: it takes the oldest match, so the answer is stable while duplicates still exist. It matches names exactly instead of as a pattern, because a folder called 100% would have matched everything starting with 100. And if the database says the name was taken a moment ago, it treats that as “someone else just made it” and uses theirs. The same audit found 92 of the 1,401 folders in production with capitalised names, all from the chat feature, which saved names exactly as the model typed them.
The 13.7 seconds
Back to that page. I recorded a production browser trace of one real load and read every request in order. Most of the 13.7 seconds was not work. It was waiting in queues nobody had chosen.
One call, folder_covers, took savesyncPrivate MergedCollections page, 13.7 s to 1.2 s One real page load, traced in production and fixed request by request: covers chosen in the database, an index put back to work, one sign-in check instead of three, and your last visit on screen while it refreshes. whole page13.7 s1.2 sslowest request7.4 s1.2 sbefore and after: production browser tracesrequests now start within 3 ms of each other 5 pull requests Choose folder covers in the database, not the browser #358 Let folder covers use their index again #364 Stream the dashboard instead of waiting on sign-in #365 Ask who you are once per page, not three times #367 Show your last visit instantly instead of a spinner #371 The repo is private. Titles and summaries are written for this page; numbers, dates and diff sizes are real. It picks a cover image for each folder. It ran with the caller’s permissions, so Postgres applied row-level security, and two security rules joined with an OR stopped it using its index. It read the whole table instead. The cost grew with the size of the table, not your library, so every user’s collections page got slower each time any user saved a link.
The function already filtered to your own account, more tightly than the security rule did, so running it with the owner’s permissions could only remove work, never rows. I didn’t want to just assert that. I compared the results both ways for every account with folders: savesyncPrivate MergedSame rows under both permissions Before changing whose permissions the call ran with, the results were compared both ways for every account that has folders. accounts identical69 of 69every account with folders, compared both waysno account saw a row it shouldn’t 1 pull request Let folder covers use their index again #364 The repo is private. Titles and summaries are written for this page; numbers, dates and diff sizes are real. Then I switched it over (#364). It runs savesyncPrivate MergedFolder covers, 9 to 13 times faster The call that picks a cover for each folder had stopped using its index and was reading every save in the database. Running it with the owner’s permissions let Postgres use the index again, and read only your rows. small account83 ms6.2 mslargest account188 ms21 mspages read14,8825,146measured on production, five warm runs each 1 pull request Let folder covers use their index again #364 The repo is private. Titles and summaries are written for this page; numbers, dates and diff sizes are real.
The rest came from the same trace. The page downloaded savesyncPrivate MergedCover data, 177 KB to 5 KB The page used to download every candidate row and pick one picture per folder in the browser. The database now picks them and sends exactly one row per folder. sent to the page177 KB5 KBone row per folder, so every folder still gets a cover 1 pull request Choose folder covers in the database, not the browser #358 The repo is private. Titles and summaries are written for this page; numbers, dates and diff sizes are real. to choose one picture per folder; Postgres now picks them and sends about 5 KB (#358). Three identical “who is this?” calls were running one after another, because the auth client queues them behind a lock, so the user is now worked out once per client (#365, #367). The remaining requests now start within 3 ms of each other instead of in single file, and the screen shows what you saw last time while it refreshes. It settled at 1.2 seconds.
505 megabytes
Supabase’s free plan holds 500 MB. On 21 September the saves table alone was 505. The plan had always been to move to Pro, $25 a month, once the database passed 400. Paying was the sensible default, and it isn’t a lot of money. But it wouldn’t have told us why a bookmark app needed half a gigabyte, so I looked first.
I audited the table first. Four of its indexes answered no query. One search index had never been used once, and still re-processed three text columns on every insert (#343). Dropping the four took the table to 468 MB.
The rest was the embeddings, the numbers that let search work by meaning. Each one was 2,052 bytes, just over the 2 KB point at which Postgres moves a value out of the row and fetches it separately. So every read that touched an embedding paid an extra trip. Half precision is 1,028 bytes and fits. Search doesn’t notice: a save searched against itself still comes back at similarity 1.000000.
The migration held an exclusive lock on the table for 3 minutes 36 seconds. The first attempt failed with “No space left on device”, which was my fault: I had asked the container for more shared memory than it had. The second ran slower and finished (#360). The table went from 471 MB just before the migration to 309. The whole database is 369 MB today, under the free plan with room to grow.
The same week, search had been quietly lying to one account. The vector index gathers its 40 nearest candidates across everyone’s saves and only then filters to yours. If your saves are a small slice of the table, the 40 run out before ten of yours appear. Search returned one to five results and called it a day, and missing results look exactly like “nothing else is relevant”. Postgres’s vector extension can now keep looking until it has ten. Recall against brute force went to 10 of 10 (#361).
The app in your hand
99 of the mobile app’s 105 pull requests are mine: the feed, folders, tags, vault chat, select mode, the share sheet, six dark themes and a light one, the widget, and the splash on the left.





The splash (#57) is a good example of what a phone teaches you that a browser preview won’t. On a real Android device, the system drew its own logo and the app drew another at a slightly different size, so for a moment there were two SAVE SYNC marks on screen. The fix was to make the system’s splash image transparent. My first “transparent” image was a single pixel that was actually half-opaque blue. Android stretched it to icon size and cut it into a circle, so the splash got a blue disc. The replacement is generated by code that checks the pixel really is empty before writing it. Then “SYNC” rendered as “SYN O”, because the layout had been measured against a fallback font.
Sharing a link on Android now saves on the spot and shows a small card over the app you were in, with an EDIT button if you want to file it properly (#173). To save without opening the app, the share code borrows your stored session. It never refreshes it. Supabase rotates refresh tokens, so if the share and the app refreshed at the same moment, one would invalidate the other and you would be signed out for the crime of sharing a link. If the session has expired, the share hands the link to the app instead. The fast path fails into the old behaviour, so it can never lose a link.
The iOS widget shows your ten newest saves (#289). The first version asked for a new snapshot every ten seconds. iOS doesn’t honour anything under a minute and gives a widget a budget of roughly 40 to 70 refreshes a day, so the widget burned through it and froze on an old snapshot. Which was the bug report. It now plans an hour ahead in five-minute steps: about 24 refreshes a day.
The save that waited
In October we shipped a new share sheet. You share a link, a card prints itself onto the screen in halftone ink, hazard stripes march while it works, and the headline rolls from SAVING… to SAVED. with a stamp and a buzz. People liked it. I liked it.
Then I recorded it frame by frame to check the Android version, and the log said the share arrived at 00:35:46.786 and the server answered at 00:35:51.735. The stripes were marching for five seconds because the server took five seconds. The animation was good enough that nobody had noticed what it was covering for.
Motion can make a wait feel deliberate. It can’t make it short.
The obvious suspect was a reader service the save calls to find a publish date, on a ten-second timeout. Timed on its own, it took 0.6 to 2.4 seconds. Slow, but not five. So I timed every phase on real links instead. There was no single villain. Each step waited for the one before it, and all of them ran before anything was saved. Saving a link you already had cost the same two or three seconds, because the “is this already here?” check came last.
Two wrong turns, both worth keeping. One run had a phase take 902 seconds, which is impossible when every call in it gives up after ten. The laptop had gone to sleep mid-run. And my own notes from September said an index was missing in production. The live database said it was there, and the query took under a millisecond. When a number is impossible, check the instrument. When the notes disagree with the system, believe the system.
The better question wasn’t how to read pages faster. It was what you are actually waiting for. When you share a link, you want to know it’s safe so you can go back to what you were doing. You don’t need its author and publish date at that moment. You aren’t even looking at the card. So a save now means the intent is captured: the link, who you are, your tags and folders. The row is written straight away from what the phone already knows, the app answers, and the full reading of the page runs afterwards and fills the card in (#399). A good waiter says “got it” first and goes to the kitchen after.
That is a one-line idea. Making it safe was the work, because now a background job and you are writing to the same row at about the same time. The job may only describe the link: title, image, author, date. Tags and folders are yours, and it never touches them. Its title only replaces the placeholder, so if you renamed the card in those few seconds, your name stands. Writing those rules down turned up an older bug: re-saving a link used to wipe its tags. Now a re-save moves it to the top and merges any new tags in.
The first deploy answered in 1.2 to 2 seconds from India, not the 0.7 I had measured. I had already been wrong twice that day, so instead of guessing again I had the route report its own timings on every save (#400) and read them from production. Every database step cost three to five times a single round trip. That shape means a brand-new connection every time. The server is in Virginia, the database is in Ireland, and idle connections close after half a minute or so. Locally I had been saving links back to back, which kept the connection warm and hid exactly the cost a real person pays. The test pattern was the bug in the measurement.
The save is now one statement over a connection that stays open (#401). The connection opens when the server starts, so the first save after a deploy isn’t paid for by whoever happens to share something first (#402). That warm-up isn’t awaited and only logs if it fails, so it can never break a deploy. Server time per save went from savesyncPrivate MergedA save, 2–6 s to about 90 ms on the server Saving a link used to read the whole page before writing anything. Now the link is written first and the page is read afterwards, in the background. Then production timings showed every save opening a fresh connection across the Atlantic, so it became one statement over a connection that stays open, warmed when the server starts. server time per save2,000–6,000 ms83–105 msre-save, timed from India2.0–3.1 s0.64–0.68 sserver time read from production, on every savestill about 100 ms after 30 s idlethe card fills in about 4 s later, in the background 4 pull requests Save first, read the page afterwards #399 Have every save report where its time went #400 Save in one round trip, over a connection that stays open #401 Open the database connection before anyone saves #402 The repo is private. Titles and summaries are written for this page; numbers, dates and diff sizes are real. SAVING… to SAVED. now takes as long as it takes to read it.
Moving the server next to the database is still on the table. It turned out to be a checklist rather than a setting, so it went to Swaroop as one.
Locks on the doors
- Row-level security on every table. Who can see a folder, and everything filed in it, is defined in one place, so the two rules can’t drift apart (#124).
- Fetching pages safely. The routes that fetch a page on your behalf only reach the public internet, and archived pages are cleaned before they render (#150, #331).
- Rate limits that hold, on search and extension saves.
- Your saves go in your vault. The save function only ever writes for the person who is signed in, whatever a request claims (#398).
- Errors that tell the truth. A failed query no longer pretends a folder is empty (#382).
What I’d tell the next person
In August I wrote a costed proposal for SaveSync: eight AI features, ten days, no new budget, built only on what it already runs. It started with an audit of 26,655 real saves, which found that the article text we scrape couldn’t be reached by keyword search or by meaning. Fixing what search can see came first. The features follow from it.
The rest I’d pass on as rules:
- When several bugs share a shape, the shape is the bug.
- Silent failure is the expensive kind. Loud failures get reported. Silent ones get blamed on the product.
- A comment that justifies code is where bugs hide. “For backwards compatibility” was deleting people’s collections.
- A fast path is only safe if its failure mode is the slow path.
- Say what state the user’s data is in, not what the software did.
- Before you pay for a bigger plan, find out what you’re storing.
- Motion can make a wait feel deliberate. It can’t make it short.
- Test with the gaps real people leave, not back to back.
Every number on this page is from a production trace, a pg_stat counter or a read-only query, and every one of them is written into the commit that changed it. That habit is the closest thing I have to a speciality.
The ledger
The performance work, fix by fix. The repository is private, so each one is retold here in plain words, with the pull requests it shipped in.
folders#34722 Sep 2026
A folder of two saves was reading all 10,026.
Opening a collection walked the whole library in sort order and asked, row by row, “is this one in the folder?” The cost followed the size of the library, not the folder, so it was worst exactly where it should have been cheapest.
Production had been saying so: one index on the join table showed about 3.5 million scans against 199 on the index the folder should have used. I turned the query round to start from the folder’s own memberships. On paper that should lose on very large folders. Measured against production, it doesn’t.
search#343 · #36023 Sep 2026
Every embedding sat just past a 2 KB cliff.
In September the saves table measured 505 MB: over the 500 MB that Supabase’s free plan allows, and past the 400 MB where the plan was to move to Pro at $25 a month. I audited it first. Four search indexes answered no query; dropping them (#343 and the cleanup before it) took it to 468 MB. The rest was the embeddings.
A 512-dimension vector is 2,052 bytes, just over the size at which Postgres moves a value out of the row. So every read that touched an embedding paid an extra fetch. Half precision is 1,028 bytes and fits, and recall doesn’t notice: a save searched against itself still comes back at similarity 1.000000.
I applied it by hand under an exclusive lock (3 min 36 s) and wrote down the trap for whoever comes next: cast the column instead of the query and it type-checks, returns the right rows, and quietly stops using the index.
search#36123 Sep 2026
Search said “that’s everything” when it wasn’t.
The vector index gathers its 40 nearest candidates across the whole table and only then filters to your account. If your saves are a small slice of the table, the candidates run out before ten of yours turn up. Search returned one to five results and reported success, and missing results look exactly like “nothing else is relevant”.
Across the 44 accounts with ten or more embedded saves, one was failing on every query I tried. pgvector 0.8’s iterative scan keeps looking until the limit is met: recall against brute force is now 10 of 10, for 12 ms on a small account and nothing on the largest.
sign-in#365 · #36723 Sep 2026
Three calls asked the same question, in single file.
Every dashboard route took about two seconds to its first byte. A production trace showed three identical “who is this?” calls running one after another, because the auth client queues them behind a lock. The queue cost more than the round trips.
The user is now resolved once per client, keyed by a WeakMap so the cache lives exactly as long as the client, and the sidebar streams in behind Suspense with a skeleton matched to its real size so nothing shifts.
bundle#36923 Sep 2026
6,146 icons were shipped to draw 80.
Two files looked an icon up by a string chosen at runtime. A bundler can’t know which names a runtime string will reach, so it kept the whole pack: a 3 MB chunk in the root layout, on every page. Now there’s an explicit list of the 80 icons a folder can actually have.
save#399 · #400 · #401 · #40216 Oct 2026
Saving a link read the whole page first.
Every save scraped the title, image and text of the page, waited on a reader service with a ten-second timeout, and only then wrote the row. Re-saving a link you already had cost the same, because the duplicate check came last. Now the row is written from what the phone already knows, and the full scrape runs afterwards and fills the card in. It may only describe the link; your tags and folders are never its to touch.
Production then disagreed with my local numbers. Timings on every save showed each database step paying for a new connection from Virginia to Ireland. The save is now one statement over a pooled connection that stays open, and the pool connects when the server boots, so the first save after a deploy isn’t paid for by whoever shares something first.