Making mobile feel unremarkable
How I made Royal Subz faster without rewriting it, learned to distrust tidy performance findings, and found a revenue bug hiding behind a stale test fixture.
Royal Subz worked on a phone. That was not the same as feeling good on one.
The catalogue opened, products could be bought, and the admin panel technically fit inside a mobile browser. But the site kept reminding people that it was a website being persuaded to behave on a smaller screen. Hover states stayed stuck after a tap. Filters occupied most of the first viewport. Product cards waited half a second before appearing because I had deliberately told them to. The hero moved as the browser bar moved. Returning to a page could write scroll position into storage on almost every frame.
None of these was a dramatic outage. Together, they made the product feel heavier than it was.
The tempting response was a mobile rewrite. I chose the less glamorous path: repair the live system in place, one reversible phase at a time, and make it stop drawing attention to itself.
The goal was not to make mobile impressive. It was to make mobile unremarkable.
I started by defining what performance work was not allowed to break
Royal Subz is a commerce system before it is a collection of Lighthouse scores. A faster page is a regression if a customer can no longer restore a cart, choose the right currency, complete a payment, or receive the product they bought.
I wrote a list of seventeen non-negotiables before changing anything. It covered the complete purchase path, saved carts, authentication, admin permissions, crawler snapshots, canonical tags, structured data, blog tables of contents, currency detection, and session behavior. These were the contracts every phase had to preserve.
Then I divided the work into eleven phases. Each phase had its own branch, verification list, deployment boundary, and rollback point. Cache policy shipped separately from scroll behavior. Mobile layout shipped separately from bundle splitting. Data-query changes did not travel with route lazy-loading just because both made the network panel look better.
I also built characterization tests around the risky existing behavior instead of immediately “cleaning it up.” The cart validator, scroll restoration, product normalization, bundle budget, and route walk all needed to describe what the site already promised before I changed how it delivered that promise.
Performance work on a live product needs a definition of “still works” that is wider than “the page rendered.”
The baseline corrected my first theory
The initial mobile Lighthouse runs made the catalogue the obvious worst page: a score of 57, a 6.1-second First Contentful Paint, and an 18.7-second Speed Index. The homepage scored 62.
I expected a main-thread problem. It was not. Total Blocking Time was only 20–40 milliseconds across the tested pages, and Cumulative Layout Shift was zero. React was not freezing the browser, and load-time layout movement was not the headline.
The expensive parts were asset delivery, image weight, bundle boot, a serial product-query waterfall, and the layout placing an entire filter panel before the first useful result. Runtime annoyances such as sticky hover and scroll-time storage writes were real, but Lighthouse could not explain those. I needed both laboratory numbers and hands-on checks, not one pretending to stand in for the other.
The first useful performance change was therefore not React at all. Production already compressed assets, but hashed files had no durable cache policy. Repeat visits revalidated every script and stylesheet. I added one-year immutable caching for /assets/, kept the HTML entry point on no-cache, and made missing asset paths return a real 404 instead of the SPA shell.
That small server change carried three traps. Combining expires with a manual header could produce two Cache-Control values. Adding a header inside the asset location could silently drop inherited security headers. Falling through to index.html for a missing hashed file could cache HTML under a JavaScript URL for a year. The change was short; proving its edges was most of the work.
Smoothness mostly came from removing work
The scroll-restoration system parsed and rewrote sessionStorage during scrolling. I replaced that with an in-memory map and persisted at navigation boundaries, page hide, or when the document became hidden. In the measurement script, twenty scroll events went from twenty storage writes to zero.
Product loading had an artificial 500-millisecond delay intended to make the transition feel polished. Removing it made the next cards appear in 6–24 milliseconds. A shimmer animated background-position; I moved it to a composited transform. Scroll and resize listeners became passive and coalesced through requestAnimationFrame. An observer leak in the reveal component was cleaned up, and critical hero content was visible on the first frame rather than waiting to be “revealed.”
One implementation detail caught me immediately. I first marked critical content inside an effect. Effects run after paint, so the first frame could still be hidden. The correct fix was to seed the initial state from the critical flag. The idea was right; its position in React’s lifecycle was not.
Touch behavior needed a different audit. One Tailwind setting, hoverOnlyWhenSupported, stopped hover: styles from becoming sticky on touch devices without rewriting roughly 150 utilities. I added deliberate press feedback, touch-action: manipulation, visible keyboard focus, 44-pixel projected targets, and 16-pixel mobile textareas to prevent iOS zoom.
That pass also found a remove button nested inside a linked product card and twenty bare buttons whose only feedback existed on hover. Mobile polish was exposing interaction semantics, not merely adding smaller spacing.
The first product card was a performance metric
On the catalogue, filters occupied the page above the results. The first product began 1,193 pixels from the top on mobile. I moved filtering into a sheet and brought that first card to 413 pixels without changing the desktop experience.
The change was visually simple and behaviorally awkward. Rendering desktop and mobile versions of the same filters created duplicate input IDs, so labels could activate the hidden copy. Route search-parameter updates accidentally triggered the global scroll-to-top behavior. Async category validation briefly showed an empty dead end before the category data arrived. Each issue came from preserving the same underlying filter behavior across two presentations.
The rest of the responsive work followed the same principle. Admin pages use document scrolling on mobile instead of nested fixed-height containers. Client-area tabs share the available width rather than forcing horizontal clipping. Product details stay stacked on tablet, while checkout earns a two-column layout earlier. A planned 40% / 60% split plus a gap turned out to be wider than its container; 2fr / 3fr expressed the intended ratio without inventing thirty-two pixels of overflow.
The hero produced the most useful real-device lesson. I replaced vh with dvh to account for mobile browser chrome. On an actual phone, the hero then grew and shrank continuously as the address bar moved—between 680 and 753 pixels in one test. dvh was doing exactly what it promised, and that promise was wrong for a minimum-height hero. I changed it to svh, which reserves the stable small viewport.
Headless tests could not prove the fix. Without browser chrome, svh, lvh, and dvh collapse to the same value. “No difference” in Playwright was inconclusive, not a pass.
I made the browser download less and ask less often
The entry path began at seven chunks and 295,541 bytes gzipped. Manual vendor-ui and vendor-utils groups made dependencies look organized while forcing code onto routes that did not need it. I removed those groups, lazy-loaded product, auth, fallback, and dashboard sections, and kept the homepage eager.
The cart validator had imported Zod into the entry path for one small persisted object. I replaced it with an exact hand-written validator, but only after tests captured its less obvious behavior: unknown keys are stripped, missing values differ from null, and NaN or infinity cannot become valid quantities. “Remove a dependency” was allowed to change the delivery mechanism, not the data contract.
After a forty-six-route walk, the entry path settled at five chunks and 236,260 bytes gzipped—a 20.1% reduction. Twelve unused dependencies and twenty-two dead modules eventually left the repository as well.
The catalogue’s data path changed from four serial product requests to one nested PostgREST query. I ran the old and new paths against all fifty-three live products and compared minimum prices, category links, and ordering field by field. Currency detection moved to a seven-day local cache with a synchronous initial read, so a returning visitor no longer watched dollars flash into taka. Public unread-count subscriptions were removed where focus-based refresh was enough, search filtering was debounced at the derived value rather than the controlled input, and footer product requests wait until the footer is near the viewport.
Images delivered another large gain, but not through an elegant image service. Supabase transformations were unavailable on the current tier, so I converted and re-uploaded all fifty-three catalogue images to WebP by hand. Homepage image bytes fell 96%; the worst image went from 320 KB to under 9 KB. I then added WebP checks to both upload forms so the migration would not depend on memory. The browser now checks the file hint, validates on selection, and validates again at the storage call.
It is a forcing function, not a hard storage constraint. A dashboard or direct API upload can bypass it. I recorded that boundary instead of pretending the UI rule solved the bucket.
A stale fixture found the most expensive bug
During the data-layer audit, checkout began rejecting the product IDs in the route-walk fixture. My first suspicion was that the fixture had simply aged. It had—but the reason mattered.
Saving any product in the admin panel deleted every variant and inserted new rows. That regenerated every variant ID and every pricing ID. Customer carts store those IDs locally, so an item added before an admin edit survived visually but failed at payment with “Invalid pricing option.” Historical invoices quietly lost their pricing_id through ON DELETE SET NULL, and variant feature links were cascade-deleted.
The image migration had re-saved all fifty-three products. The performance programme had not created this bug; it had exercised it at catalogue scale and left the stale IDs as evidence.
I replaced delete-and-recreate with reconciliation. Existing rows update in place, new rows insert, and only genuinely removed rows delete. The reconciliation lives as a pure function with focused tests, including the stale-form case where an submitted ID no longer exists. Because PostgREST can report an update matching zero rows as success, that case becomes an insert rather than silently losing the variant.
The fix prevented recurrence, but it could not heal carts already holding dead IDs. I chose to bump the cart storage key and clear them once. That discarded some still-valid items, but it was kinder than letting customers discover an unrepairable cart at the payment step. I verified the final behavior in production: add a product, save it again in admin, and take the original cart through to payment.
Finding that bug was worth more than the Lighthouse gain.
The numbers improved, but the method changed more
The final catalogue score moved from 57 to 78. First Contentful Paint fell from 6,062 to 2,421 milliseconds, Speed Index from 18,735 to 6,726, and page weight from 685 KB to 337 KB. The homepage moved from 62 to 72, with First Contentful Paint down 39% and page weight down 8%.
Homepage Speed Index was 6% worse in the final single run. I did not call it a regression or hide it inside the other numbers; it sits inside the variance observed that day and still needs a median-of-three. Three other baseline routes also need final Lighthouse runs. The real-device pass across iPhone, Android, and tablet remains owed, especially around admin permission boundaries and the viewport-height fix.
The programme repeatedly taught me to distrust measurements that looked complete. One audit counted Crisp as a 3.2 KB script but missed the roughly 120 KB widget it loaded. Another found 620 KB of payment logos in the source tree, but the components importing them were unreachable, so no customer had downloaded a byte. A dependency scanner labelled tailwindcss-animate unused even though Tailwind loaded it as an active plugin. Playwright auto-scroll contaminated one fold measurement. Grepping a minified bundle for original identifiers “proved” code was gone because minification had renamed it.
Static measurements were useful beginnings. None were evidence until I traced what shipped, what executed, and what a person could actually experience.
I started with a request to make mobile faster. The implementation became cache policy, scroll lifecycle, touch semantics, responsive information hierarchy, route splitting, query design, image discipline, dead-code removal, and one repaired revenue boundary.
The finished site still looks like Royal Subz. It just reaches the first product sooner, responds when touched, keeps its hero still, downloads less, asks the database fewer questions, and preserves a customer’s cart when an admin edits a product.
That is the kind of performance improvement I wanted: not a feature people have to notice, but the absence of reasons to notice it.