Boost Web Performance with client-side prediction

Slow websites drive users away. Even well-optimized websites can suffer from slow navigation due to poor internet connection or server overload. Achieving consistently fast navigation is a challenging task. Optimizations like prefetching or AI-driven prerendering improve navigation but can be inconsistent and resource-intensive. The approach described in this article article goes the other way; it aims to create the illusion that the content is already loaded instead of wastefully preloading it.

TLDR

Navigation from the entity list to the entity detail page can feel instant if the page is rendered optimistically with predicted data that fills the entire viewport, hiding unloaded sections deeper into the page view. By using data from the entity list as predicted data, additional network requests can be avoided. Here's an implementation of that idea.

Motivation

My goal was to create a site with consistently fast soft (client-side) navigation, regardless of the user's internet speed or CPU performance. Prefetching almost fits as a solution, but it lacks consistency; if a user clicks the link before the content has preloaded, they will experience a delay. Besides, prefetching at scale results in significant costs—both financial and environmental.

Vercel Hosting Cost
100K - 5M page views per month
Carbon Footprint
100K - 5M page views per month

Without prefetching, you can scale up to 1 million views per month and pay only $20 (for a single user account), while with prefetching you would pay around $500. Based on Pro plan limits:

  • Available Bandwidth: 1024 GB / 1TB
  • Available amount of edge requests: 10_000_000 (10 M)

Extra bandwidth costs $0.15 per GB over the limit; that's the main item of expenditure (≈90%). Extra Edge Requests cost $2 per million over the limit.

Implementation

In that demo I tried to achieve a seamless navigation experience from the listing to the detail page. I love doing micro-optimizations and that project has tons of them:

  • Seamless navigation: optimistic UI with client-side prediction for synchronous transitions.
  • Optimized responsive images: With ThumbHash for rendering placeholders and image preloading strategies to enhance loading speed.
  • SSR to SPA transition: During soft navigation, runs route data requests through React Query for efficient caching and management. It's achieved by using createIsomorphicFn with empty actions on client as route loader function.
  • Route chunk preloading: Automatically preloads JS route chunks during initial load to reduce latency.
  • Granular optimistic UI: Utilizes React Query for more accurate management of optimistic UI updates, avoiding unnecessary re-renders that occur with Suspense.
  • View Transitions API: Shows complex animations of multiple elements during navigation to the article.

The Problem: Slow Navigation and Unresponsiveness

Picture this: you click a link and await one second for a server response; the UI is frozen, and the website feels broken. To make this article clearer, I made a simple demo app that imitates such navigation.

In the demo app below, try clicking the links to experience the delay in navigation. The app includes two links: one to the 'Lorem Ipsum' article and another to the 'Home' page.

Solution: Client-side prediction

To address the problem of sluggish navigation, we can borrow techniques from other fields, like gaming. Client-side prediction is a technique that enhances gameplay by letting the client respond instantly to inputs, reducing the feel of lag. While it causes a temporary desync between client and server, smoothing techniques mitigate the delay, balancing responsiveness and accuracy. In web development, client-side prediction can be used within optimistic UI update. Here's a scenario:

  1. User clicks a link or button (e.g., "Like").
  2. The UI instantly updates with a predicted outcome (e.g., Like count increases by 1).
  3. A request is sent to the server in the background.
  4. The UI syncs with the server response, adjusting if necessary.

Use it only if you are at least 97% sure that prediction will succeed and the server won't return an error. Don't use it with sensitive or rapidly changing data. When implementing optimistic UI updates, adhere to these guidelines:

  • Synchronize with Server Response: Always update the UI to reflect the actual server response. It's can create flashes of incorrect content.
  • Handle Errors Gracefully: If the server returns an error, display an appropriate error message instead of predicted content. Alternatively, the error message can be shown after predicted content.

Performance illusion

You can achieve the illusion of instant navigation by rendering the opened page optimistically and filling the entire viewport with predicted data. It allows users to interact with the page immediately; for example, adding a product to the cart, navigating to the category page of that entity, or simply scrolling and reading content.

Here's an implementation of that optimization (source code). Reduce your internet speed to 3G (via network throttling in dev tools) and test it yourself.

That technique hides loading sections by putting them deeper into page view. In most cases, users never see loading sections unless they scroll very quickly. Even if they do notice, engaging with predicted content beats staring at a blank screen. The demo app below shows that illusion in action.

It's like a waiter serving you an aperitivo while the main course cooks.

  • Traditional Navigation: Click → Wait → Render.
  • Prefetching: Preload → Click → Render.
  • Optimistic UI Navigation: Click → Render Predicted Data → Sync with Server.

Recycling Data: Utilizing What’s Already There

In the demo app above, clicking on an article card takes you to the article page, which displays the same thumbnail, title, and description. From a data perspective, on the home page, we make a request for a list of articles, and on the article page, we request the article detail object.

Typically, a query for a list of entities returns partial data, while a query for an entity object returns its full data. Use the data from the list of entities to render the detail page optimistically. The more data you get in the entity list, the more content can be rendered instantly.

A page rendered with predicted data can distract the user from network lag for 2-10 seconds. Predicted data fills the entire viewport, revealing unloaded sections only when the user scrolls. Such optimization lets the server answer slowly, which can be useful for load balancing.

Disclaimer

When the user opens the listing page, explores it, and clicks on the item, there's a chance that the entity item is updated during page exploration; in that case, the user will see a flash of outdated content. To minimize the risk of such flashes, set listing request staleTime to one minute. If content of the website is change rapidly and load data through websocket, use latest data from websocket as predicted data.

View Transitions at fingertips

Client-side predictions eliminate network delays, letting React render the opened page in 16 ms. It makes the View Transition API run animations without delays right after the user presses the link.

Such navigation feels seamless, like flipping through a glossy magazine, it increases user engagement and encourage exploring more website content.

Exposing Heavy Code

After eliminating network delays during navigation, software delays will be more visible. Heavy libraries/components often cause these delays.

Consider refactoring if the "Interaction to Next Paint" metric takes more than 32 milliseconds in a production build (you can find that metric in Chrome developer tools on the "Performance" tab; it runs on navigation).

Drawbacks of that approach

No solution is flawless. Here are the trade-offs:

  • A flash of incorrect content. Failed predictions for sensitive data, such as product prices and availability of goods, create issues that erode trust more significantly than slow loading times. You can reduce the risks of failed predictions by setting the staleTime of listing requests to 60 seconds or starting to use WebSockets. As an alternative, avoid using predictions for that data and render a skeleton.
  • Increased entity list response size. The main idea behind this optimization is to use data from the entity list to optimistically render the opened entity detail page. The more predicted data you receive in an entity list request, the more page content can be rendered optimistically.
  • RAM usage. Storing predicted data costs memory; if you have pages that show thousands of links simultaneously, avoid using that optimization.

Implementation

My implementation makes Tanstack Router skips calling of router data requests during client (soft) navigation and runs them through react-query. It gives tons of features, including optimistic UI updates.

Bellow I will describe main features of this starter.

Isomorphic loader function

Every loader is created through createIsomorphicFn. It lets loader await for network requests only on server side (to generate HTML), while on client-side it skips all the work to let react-query fetch route data. By doing that you can make Node.js layer run only during hard navigation and IDLE during soft navigations, it'll make your website more sustainable and energy efficient.

const myLoader = createIsomorphicFn()
  .server(async ({ params: { postId }, context }) => {
    const data = await context.queryClient.ensureQueryData(
      blogItemPageOptions(postId),
    )
    return {
      title: data.attributes.title,
    }
  })
  .client(() => {})

Error handler HOC

By skipping network calls during soft navigation, we need to explicitly handle network errors on the client. To achieve it, I created WithErrorHandler component, that wraps page view.

import { ParentComponent } from '@/types/general';
import React from 'react';
import { ErrorRouteComponent, NotFoundRouteComponent } from '@tanstack/react-router';

interface Props {
  notFoundComponent?: NotFoundRouteComponent;
  errorComponent?: ErrorRouteComponent | false | null;
  error: Error & { isNotFound: boolean } | Error | null;
}
export const WithErrorHandler: ParentComponent<Props> = ({ errorComponent: ErrorComponent, notFoundComponent: NotFoundComponent, error, children }) => {
  if (error) {
    if ('isNotFound' in error && error.isNotFound) {
      return NotFoundComponent ? <NotFoundComponent data={{}} /> : null;
    }
    return ErrorComponent ? <ErrorComponent reset={() => {}} error={error} /> : null;
  }
  return children
}

I made a Link component that wraps @tanstack/react-router Link and extend it by adding placeholderData prop. With that component website links can store predicted data of the page they open. When Link is clicked it updates placeholderData singleton, that will be used for optimistic rendering.

import React, { PropsWithChildren } from 'react';
import { Link as TanstackLink, LinkProps } from '@tanstack/react-router';
import { setPlaceholderData } from '@/singletones/placeholderData';

type Props = LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement> & {
  placeholderData?: object;
}

export const Link: React.FC<PropsWithChildren<Props>> = ({ children, onClick, placeholderData, ...props }) => {
  const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
    if (onClick) {
      onClick(e);
    }

    setPlaceholderData(placeholderData);
  }

  return (
    <TanstackLink
      onClick={handleClick}
      {...props}
    >
      {children}
    </TanstackLink>
  )
}

Conclusion

An optimistic UI with predicted data can create the illusion of consistently fast navigation. When it works, it delights users, boosts engagement, and enhances Web Vitals metrics. But when a prediction fails, it confuses users, potentially damaging website reputation.

You can reduce the risks of failed predictions by setting staleTime of listing requests to 60 seconds or start using websocket.