ଅ case study

Case study · SaveSync

SaveSync

The bookmark app that kept forgetting where it put things, and five months of making it stop.

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 AswinBehera merged on 16 Oct 2026 · +483 −166 · 9 files Have every save report where its time went #400 AswinBehera merged on 16 Oct 2026 · +30 −5 · 2 files Save in one round trip, over a connection that stays open #401 AswinBehera merged on 16 Oct 2026 · +242 −3 · 4 files Open the database connection before anyone saves #402 AswinBehera merged on 16 Oct 2026 · +33 −0 · 2 files The repo is private. Titles and summaries are written for this page; numbers, dates and diff sizes are real. to save a link, on the server. It used to read the whole page before writing anything. Now it writes first and reads later. The save that waited ↓
61,145bookmarks saved today, across 160 accounts, up from about 500 when I joined
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 AswinBehera merged on 23 Sep 2026 · +53 −19 · 2 files Let folder covers use their index again #364 AswinBehera merged on 23 Sep 2026 · +121 −0 · 1 file Stream the dashboard instead of waiting on sign-in #365 AswinBehera merged on 23 Sep 2026 · +109 −29 · 3 files Ask who you are once per page, not three times #367 AswinBehera merged on 23 Sep 2026 · +111 −23 · 4 files Show your last visit instantly instead of a spinner #371 AswinBehera merged on 23 Sep 2026 · +204 −6 · 4 files The repo is private. Titles and summaries are written for this page; numbers, dates and diff sizes are real. the collections page, traced request by request in production
savesyncPrivate MergedThe saves table, 505 MB to 309 MB An audit before paying for a bigger plan. Indexes that answered no query went first, then the search embeddings moved to half precision, small enough to sit inside the row. Search results didn’t change. saves table505 MB309 MBvector index94 MB46 MBback under the 500 MB free plan, with room to growa save searched against itself still scores 1.000000 2 pull requests Remove a search index no query ever used #343 AswinBehera merged on 22 Sep 2026 · +82 −0 · 1 file Store search embeddings at half precision #360 AswinBehera merged on 23 Sep 2026 · +119 −0 · 1 file The repo is private. Titles and summaries are written for this page; numbers, dates and diff sizes are real. the saves table: from over Supabase’s 500 MB free plan to well under it, so no $25/month upgrade
savesyncPrivate 241 merged of 264 opened, since July 2026 15Jul139Aug74Sep13Oct The repository has had 333 pull requests in all. Most of mine are the mobile app (110 of its 116) and every one of its performance fixes. The repo is private. Titles and summaries are written for this page; numbers, dates and diff sizes are real. pull requests merged since July, out of 264 opened. August alone was 139.

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 AswinBehera merged on 23 Sep 2026 · +53 −19 · 2 files Let folder covers use their index again #364 AswinBehera merged on 23 Sep 2026 · +121 −0 · 1 file Stream the dashboard instead of waiting on sign-in #365 AswinBehera merged on 23 Sep 2026 · +109 −29 · 3 files Ask who you are once per page, not three times #367 AswinBehera merged on 23 Sep 2026 · +111 −23 · 4 files Show your last visit instantly instead of a spinner #371 AswinBehera merged on 23 Sep 2026 · +204 −6 · 4 files 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 AswinBehera merged on 23 Sep 2026 · +121 −0 · 1 file 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 AswinBehera merged on 23 Sep 2026 · +121 −0 · 1 file 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 AswinBehera merged on 23 Sep 2026 · +53 −19 · 2 files 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).

01 KB2 KBTOAST
Trip lever: lifts when a value is too big to stay in the rowOne embedding: 512 numbers at 4 bytes each, or 2 at half precisionPostgres moves a value out of the row once the row passes about 2 KB (2,032 bytes on 8 KB pages).

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.

SaveSync on iPhone: the dashboard feed of saved links with tags and thumbnails.
The feed
SaveSync on iPhone: the sidebar with all saves, highlights, web archive and each platform.
Every way in
SaveSync on iPhone: the tags screen with each tag and its count.
Tags
SaveSync on iPhone: the account screen with saves, collections and the theme colours.
Account & themes

iPhone 17 Pro simulator, the current release build in its light theme, signed in to the App Review demo account.

savesync.org: Everything saved, nothing lost. The privacy-first AI bookmark manager, on cream with yellow.
The web app’s front door, savesync.org, as it is today. Same cream, same ink, same hard shadows as the phone.

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 AswinBehera merged on 16 Oct 2026 · +483 −166 · 9 files Have every save report where its time went #400 AswinBehera merged on 16 Oct 2026 · +30 −5 · 2 files Save in one round trip, over a connection that stays open #401 AswinBehera merged on 16 Oct 2026 · +242 −3 · 4 files Open the database connection before anyone saves #402 AswinBehera merged on 16 Oct 2026 · +33 −0 · 2 files 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.

DESCRIBEDSAVED
Your link: SAVED lights when it reaches the end of the main lineA station a save waits at; it brightens while busyServer time per save, in production: 2–6 s before, 83–105 ms after (#399, #401). The card is fully described about 4 s later, in the background.

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.

open a folder of 2 · best of five, production before 0.682 s after 0.352 s
open a folder of 3,002 before 0.772 s after 0.440 s
Open a folder from its own contents, not the whole library #347 AswinBehera merged on 22 Sep 2026 · +211 −22 · 2 files

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.

saves, total relation · audit → dead indexes dropped → halfvec before 505 MB after 309 MB 505 MB on 21 Sep · 468 MB after the dead indexes · 471 MB just before this migration · 309 MB after. The whole database is 369 MB today.
HNSW vector index before 94 MB after 46 MB
Remove a search index no query ever used #343 AswinBehera merged on 22 Sep 2026 · +82 −0 · 1 file Store search embeddings at half precision #360 AswinBehera merged on 23 Sep 2026 · +119 −0 · 1 file

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.

results returned, out of 10 · the failing account before 1–5 after 10
Make search return all ten results, for every account #361 AswinBehera merged on 23 Sep 2026 · +101 −0 · 1 file

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.

data queries start · /dashboard/collections before 2,494 ms after 1,650 ms after = expected from the trace; measured once deployed
Stream the dashboard instead of waiting on sign-in #365 AswinBehera merged on 23 Sep 2026 · +109 −29 · 3 files Ask who you are once per page, not three times #367 AswinBehera merged on 23 Sep 2026 · +111 −23 · 4 files

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.

first-load JS /dashboard 1.07 MB 715 kB /collections 706 kB 349 kB
Ship the 80 icons the app uses, not all 6,146 #369 AswinBehera merged on 23 Sep 2026 · +159 −46 · 6 files

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.

server time per save · production, read from the route’s own timings before 2,000–6,000 ms after 83–105 ms Write-first alone (#399) brought it to 300–1,850 ms. One statement over a pooled connection (#401) did the rest, and holds after 30 s idle.
re-saving a link you already had · end to end, from India before 2.0–3.1 s after 0.64–0.68 s
Save first, read the page afterwards #399 AswinBehera merged on 16 Oct 2026 · +483 −166 · 9 files Have every save report where its time went #400 AswinBehera merged on 16 Oct 2026 · +30 −5 · 2 files Save in one round trip, over a connection that stays open #401 AswinBehera merged on 16 Oct 2026 · +242 −3 · 4 files Open the database connection before anyone saves #402 AswinBehera merged on 16 Oct 2026 · +33 −0 · 2 files

Visit SaveSync →

The World Beneath

turn-based roguelike · 8×8 textmode · CRT

Under the site there’s a small world. Click the screen to start: pick a companion, find the ten caches, and see how far down the temple goes.

The ring

water douses fire · fire burns root · root grounds spark · spark shocks water. Lead with what beats what’s in front of you: ×2 one way, ×½ the other.

Walk
arrows, WASD, or click
Act
Enter · Z waits · M menu
Margins
more marks hide in long reads
·