Skip to main content

A JavaScript Tab-Switch Audit: Keep the Navigation, Skip Repeated Theme Work

Updated 6 min readMarkdown

A tab can feel slow even when its click handler contains no network request. Rendering the next view, reapplying a theme, measuring the navigation and starting several animations can all happen during the same interaction.

This came up while working on Cora at HCS App Studio. Cora uses a JavaScript interface inside a Flutter WebView. A report of sluggish tab changes led to a small rendering audit. The changes described here are in the local source and browser preview; they have not yet been packaged into a new Android release.

The useful question was: which work actually needs to happen when the selected route changes?

Establish a comparison before changing the animation

The test used the backed-up interface and the modified interface in the same local Chrome session, with separate local origins. Both used a 390 × 844 viewport, Chrome's 4× CPU slowdown and 30 synthetic daily records. Each version received 15 tab clicks: three passes through the same five routes.

Two measurements helped separate JavaScript work from the visible transition:

  • A wrapper around the full render function recorded its synchronous execution time.
  • A capturing click listener recorded the elapsed time until the second requestAnimationFrame callback.

Here is the simplified timing probe:

document.addEventListener('click', (event) => {
  const tab = event.target.closest('nav [data-action="nav"]');
  if (!tab) return;

  const started = performance.now();
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      samples.push(performance.now() - started);
    });
  });
}, true);

That second number is a local scheduling proxy. A requestAnimationFrame callback runs before a repaint; two callbacks do not prove when a frame reached the physical display. This measurement is not INP and does not include the phone's hardware input path. MDN explains the callback timing.

1. Leave an unchanged theme alone

The theme application path removed and reapplied inherited style properties during route rendering. A new page needed the current palette, but the palette itself usually had not changed.

The fix was an early return after normalizing the requested theme. This excerpt shows the guard; the existing palette updates remain after it:

let appliedTheme;

function apply(chosen) {
  const theme = isKnownTheme(chosen) ? chosen : 'default';
  if (theme === appliedTheme) return;

  appliedTheme = theme;
  // Existing palette updates follow.
}

This guard is valid because this path owns the palette properties. Dark and system modes are handled independently. If several parts of an application can overwrite the same properties, caching only the theme name can leave the UI stale. Either centralize that ownership or invalidate the cache when an external writer changes it.

The test therefore checked actual theme changes as well as route changes: seven palettes in light and dark modes, with their resolved colors retained across tabs.

2. Keep the navigation nodes between routes

The bottom navigation has five buttons. Replacing those buttons on every route change was unnecessary. Its labels depend on language; its selected state depends on the route.

The revised path rebuilds labels when the language changes, then updates selection on the existing buttons. This is a simplified extract of that boundary:

if (nav.dataset.language !== language || !hasNavigation(nav)) {
  rebuildNavigationLabels(nav, language);
  nav.dataset.language = language;
}

for (const button of nav.querySelectorAll('[data-action="nav"]')) {
  const selected = button.dataset.id === route;
  button.classList.toggle('active', selected);

  if (selected) button.setAttribute('aria-current', 'page');
  else button.removeAttribute('aria-current');
}

The main route content still rerenders. This was a targeted change, not a new rendering framework. Keeping the navigation also preserves button identity and keyboard focus.

A second guard avoids navigating to the route that is already active. That fixed a correctness issue too: tapping the selected Journal tab no longer replaces an unsaved entry with a fresh render.

3. Group navigation measurements and cancel stale work

The selected-tab marker used to read geometry around the page render. The revised path schedules one geometry-reading batch after the render extensions complete, reuses the marker and cancels its previous animation before starting another.

Reading layout-dependent properties after changing styles can force synchronous layout. Moving code into requestAnimationFrame does not automatically prevent that. The relevant change is reducing repeated reads and grouping the reads before the marker writes. This layout guide explains the read/write ordering problem.

Rapid navigation also needs a stale-work check. The arrival animation now has one cancellable scheduled callback, checks that its route is still current and cancels animations whose targets have left the document. The scheduling pattern is:

cancelAnimationFrame(arrivalFrame);
const expectedRoute = currentRoute;

arrivalFrame = requestAnimationFrame(() => {
  arrivalFrame = 0;
  if (currentRoute !== expectedRoute || !motionAllowed()) return;
  animateCurrentCards();
});

This changes when decorative work starts; it does not move the work off the main thread. A ResizeObserver also repositions the navigation marker after the navigation changes size.

4. Shorten the arrival, keep the feedback

The previous card arrival could extend to about 590 ms with its stagger. The revised animation affects at most four top-level cards: 180 ms per card with a 15 ms stagger, ending within 225 ms. The translation is small and the cards remain mostly opaque throughout.

Both the device's reduced-motion setting and the application's motion preference still apply. Shortening an animation must not remove the user's ability to disable it. Success feedback also remains tied to successful operations rather than just button clicks.

What changed in this local run?

These are the paired results from 15 switches per version:

Measurement Before After
Median click capture to second animation-frame callback 50.8 ms 37.9 ms
Median synchronous render-wrapper duration 36.5 ms 8.1 ms
Total layout duration across the switch sequence 166.2 ms 155.9 ms
Total style recalculation duration across the sequence 343.2 ms 234.0 ms
Observed long tasks over 50 ms 6 3

The click-to-callback median decreased by about 25% in this run. Layout work is still present, and the much larger reduction in synchronous render time does not translate directly into the same percentage of perceived improvement. The result combines several changes; it does not isolate the contribution of each one.

This is a small local comparison, not a production percentile or a physical-device benchmark. Machine load, caches, recording overhead and the route sequence affect the numbers. The next release still needs device profiling before making an Android performance claim.

Check what a faster render might break

The validation included 61 passing unit tests and nine browser check groups. Those covered navigation identity and selection, unsaved Journal input, explicit saving, three interface languages, 14 theme/mode combinations, marker alignment at three widths, reduced motion, the in-app motion preference, rapid navigation and records surviving reload.

The important boundary was that navigation and decoration could change without changing stored records, calculations, billing or advertising behavior. A saved record remained authoritative; an animation did not become part of the persistence logic.

For a similar interface, start by looking for work that runs on every render but depends on something that rarely changes: language, palette, stable navigation or static decoration. Measure a fixed route sequence, keep the data constant and check correctness alongside timing. Small rendering boundaries can remove repeated work without requiring a framework migration.

About the author

  • HCS App Studio is the product journal of HCS Yazılım, the developer behind Cora: Journal & Calendar for Android. We share practical engineering decisions, real app screens with illustrative entries, and usage guides. Website: https://belgekasam.site/en/cora

No comments yet. Be the first to comment.