·9 min read·Deep Dive

Measure First: Building PayTable's New Guest Experience

PayTable's new guest menu shipped in production on August 27 and kept moving fast after that. Here's the measurement-first approach behind it — what we didn't build, and how we chased smoothness down to the frame.

By Igor Riera

PayTable’s guest experience is a website, on purpose. A diner scans the QR code on their table and gets a menu in their own phone’s browser — no app store, no install, no waiting. That constraint is also the whole design brief: whatever we build has to feel instant on hardware we don’t control, over WiFi we don’t control, in a browser we didn’t pick.

We shipped a new version of that experience — new hero layout, new scroll behavior — as v2.0.0 on August 27. Production was on v2.4.1 eighteen days later. What we want to walk through isn’t the release cadence, though — it’s the discipline behind it. Before we optimized anything, we measured it. Before we added complexity, we checked whether we actually needed it. And where the polish mattered, we chased it down to individual frames.

What changed, and why the mechanics matter

The redesign moved the restaurant’s hero image from a static banner into a background layer that sits behind the menu as you scroll. The menu itself moved from category tabs — tap “Starters,” see starters — to one continuous scroll with a navigation bar that tracks where you are as you move through it.

That single change created a small but real engineering problem: the element that scrolls is different depending on which part of the experience you’re in, so a navigation bar that “knows where you are” can’t just listen for scroll events on one fixed container. We track position with a scroll listener registered in the capture phase on document, so it sees scroll events regardless of which element they originate from:

document.addEventListener('scroll', updateActiveSection, {
  capture: true,
  passive: true,
})

Small detail, but it’s the kind of detail that decides whether the nav bar feels attached to the menu or one step behind it.

Measure before you virtualize

A continuous-scroll menu with dozens of dishes and photos raises an obvious question: do we need list virtualization — only rendering the rows currently on screen, the way a long chat log or a spreadsheet does?

Virtualization is a real technique with a real cost. It breaks browser find-in-page, it complicates scroll-position restoration, and it takes real, ongoing effort to keep accessible for screen readers. So instead of assuming we needed it, we built a small measurement harness and rendered a large menu — 280 dishes — through it. Result: 3,617 DOM nodes and 35 milliseconds of layout time. That’s comfortably inside budget for a scrolling page, so we didn’t add virtualization. The complexity would have been a cost with no matching benefit.

The harness told us something more useful than “you’re fine,” though — it showed us where the actual weight was. It wasn’t the DOM. It was the dish photography. That reframed the optimization question entirely: instead of asking how to render fewer elements, we asked how to move fewer bytes.

One concrete result of that: before an order is placed, the app re-checks the menu against the server so the diner is confirming current prices. That check has no use for pictures, so it asks for the restaurant read with includeMedia=false. On a large demo menu, that turns a 13.29 MB response into 121 KB — 93% of the full payload was images. The media-free response even gets its own TypeScript type, so the compiler refuses any attempt to store it where the app expects a full menu with photos.

Chasing smoothness to the frame

Getting the scroll-linked hero and navigation to feel smooth wasn’t a single fix — it was a sequence of measurements, each one narrowing where the cost actually was.

Per-frame cost during scroll dropped from 3.67 ms to 1.81 ms. Layout reads per frame dropped from 10.6 to 2.8. The worst single frame we measured went from 1,002 ms — a full second of stall — down to 8.8 ms.

The biggest single contributor to that stall was a scoping mistake in how we drove the scroll animation with CSS custom properties. The animated properties had been declared on :root, which sounds harmless but isn’t: changing a custom property on the document root forces the browser to consider restyling every single element in the document on every frame, whether that element uses the property or not.

/* forces the browser to re-evaluate every element in the document, every frame */
:root {
  --hero-scale: var(--scroll-scale);
}
/* scoped to the elements that actually read it */
.hero {
  --hero-scale: var(--scroll-scale);
}

On a page with a few hundred dish elements, that’s the difference between a browser doing a few frames of focused work and a browser doing a full-document sweep sixty times a second. Scoping the properties to the elements that actually use them was most of the win right there.

120Hz phones don’t play fair

Newer phones run their displays at 120Hz, and browsers don’t guarantee you a scroll event for every one of those frames — they coalesce events under load, which can mean you’re handed half the frames or fewer. A scroll effect built to trust every scroll event as a source of truth will visibly stutter on exactly the hardware that should look best.

The fix is to stop trusting the event stream as the clock. A requestAnimationFrame loop re-reads the actual scroll position on every frame the browser gives it, rather than waiting to be told a scroll happened:

function follow() {
  const y = scrollContainer.scrollTop
  applyScrollEffects(y)
  requestAnimationFrame(follow)
}
requestAnimationFrame(follow)

We also added will-change hints on the elements doing the animating, which gives the browser advance notice to put them on their own compositor layer instead of deciding that mid-scroll.

An iOS layout quirk worth chasing down

Polishing an animated, scroll-heavy interface on a real device turns up things a code review never will. One of ours: on iOS specifically, a menu sheet meant to sit fixed at the bottom of the screen was landing 58 pixels off — but only when a banner happened to be showing above it.

The cause was a chain of CSS behavior that’s individually reasonable and jointly surprising. Ionic animates page transitions on iOS with a translateX transform on ion-content. A position: fixed element normally positions itself relative to the whole viewport — but a transformed ancestor becomes the new containing block for any fixed descendants inside it. So our “fixed to the screen” sheet was actually fixed to a transformed container instead, and the banner changed that container’s size just enough to throw the offset off.

We found it the way you find most micro-layout bugs: by adding a temporary on-screen debug readout, switched on by a query flag, and filming the transition on an actual phone until the numbers lined up with what we were seeing. The fix was one declaration: contain: layout on ion-content, which makes it the containing block permanently instead of only while a transition’s transform happens to be applied — so the sheet anchors to the same box every time.

ion-content {
  contain: layout;
}

It’s a small fix with an outsized payoff, because a menu sheet that’s off by 58 pixels on one platform is the kind of thing that erodes trust in an interface without a diner ever being able to say exactly why it feels wrong.

Text shouldn’t shimmer

The restaurant name at the top of the menu shrinks slightly as you scroll past it — a small piece of choreography, but one that’s easy to get wrong. Our first pass drove it by writing a new scale value directly on every scroll event. That works, but it has a side effect most people don’t expect: changing a static transform value forces the browser to re-rasterize the text underneath it, which reads to the eye as a faint shimmer or jitter, even when the frame rate itself is healthy.

The fix was to stop writing the value from JavaScript at all and let the browser’s own compositor own the animation, using a CSS scroll-driven animation bound to the menu’s own scroll position:

@keyframes shrink-name {
  from { transform: scale(1); }
  to   { transform: scale(0.85); }
}

ion-content::part(scroll) {
  scroll-timeline: --menu-scroll block;
}

.restaurant-name {
  animation: shrink-name linear;
  animation-timeline: --menu-scroll;
}

One gotcha along the way: the unnamed shorthand for “the nearest scrollable ancestor,” scroll(nearest), silently resolved to an overflow: hidden wrapper elsewhere in the page instead of the actual scrolling container we meant. Naming the timeline explicitly, as above, removes the ambiguity. Because scroll-driven animations aren’t supported everywhere yet, this path is feature-gated to browsers that support it (Chrome 115+, Safari 26+), with the JavaScript-driven version as the fallback elsewhere.

It’s also worth saying how we keep these fixes fixed: animation and timing tests in our suite run against a controlled clock instead of real elapsed time, specifically because real timing on the same test varied by as much as 4x depending on execution order — not a foundation you can build a regression test on.

Speaking the guest’s language, without fighting the browser

The guest menu ships 34 language bundles, translated from a Spanish source — restaurants in Mexico serve plenty of diners who don’t read Spanish, and the app needs to meet each of them where they are. A detail that matters as much as the translations themselves: the already-localized text is marked so that a browser’s own auto-translate feature doesn’t try to translate it a second time and mangle it in the process. A guest’s chosen language sticks for six hours, scoped to that specific restaurant, so switching languages at one table doesn’t follow them to the next one they visit that day.

Apple Pay became available directly on the customer-info step of checkout on September 12 — one more way to get through checkout without typing a card number into a phone keyboard at a restaurant table.

Measuring what the guest actually does

All of this — the frame budgets, the payload sizes, the language handling — is aimed at one outcome: a menu that feels like it’s just there, not like it’s working to be there. To know whether we’re hitting that, we collect UX telemetry in batches via sendBeacon, so it doesn’t compete with the page for bandwidth at the moments that matter. “Time on page” specifically is measured as engaged time using performance.now(), not wall-clock time — a phone left face-up on the table for an entire meal shouldn’t count as forty-five minutes of someone reading the menu.

Shipping fast because the checks are automatic

Going from v2.0.0 to v2.4.1 in eighteen days only works if releasing isn’t the risky part. Guest app releases to production are a deliberate manual step — someone decides it’s time — but once triggered, we verify the release actually landed by matching the deployed build’s git commit against a /version.json endpoint the app serves itself. There’s no ambiguity about whether the frame-timing fix, the includeMedia change, or the Apple Pay step is actually live: the running app tells you exactly what commit it’s built from.

Underneath all of it sits the guest app’s own test suite — 2,016 Vitest unit tests and 998 Cypress end-to-end tests, with dedicated Cypress specs covering the hero choreography and the navigation scrubbing directly, so a regression in either one fails a test before it fails a diner’s first impression.

The principle

None of the individual numbers here — 3,617 nodes, 8.8 ms worst frame, 121 KB instead of 13.29 MB — matter as trivia. What they add up to is a way of working: measure the actual cost before you build around an assumed one, prefer removing weight over adding cleverness, and when something needs to feel smooth, verify it on the real hardware it has to feel smooth on. That’s what “measure first” means in practice — not a slogan, just the order operations happen in.