Five Frontend Patterns That Make Applications Feel Faster
Explore five essential frontend patterns that make applications feel faster and more responsive: request deduplication, optimistic updates, streaming UI, stale while revalidate, and smart polling.
A fast frontend is not only about reducing bundle size or improving Lighthouse scores. Performance is also about how an application behaves while users interact with it.
Does the interface respond immediately after a button is clicked? Does it avoid sending the same request multiple times? Can it show useful content before the entire response is ready? Does it keep information fresh without constantly displaying loading screens?
These questions are addressed by five important frontend patterns:
Request deduplication
Optimistic updates
Streaming UI
Stale while revalidate
Smart polling
Each pattern solves a different problem, but they all share the same goal: reduce unnecessary waiting while keeping the interface responsive and trustworthy.
1. Request Deduplication
Modern frontend applications often have multiple components that depend on the same data.
For example, a user profile may be needed by the navigation bar, account page, notification menu, and settings panel. If every component independently requests the same endpoint, the application may send several identical requests at nearly the same time.
This creates unnecessary network traffic and can place additional pressure on the backend.
Request deduplication prevents this by allowing identical requests to share the same pending promise.
const pendingRequests = new Map<string, Promise<unknown>>();
async function fetchOnce<T>(url: string): Promise<T> {
const existingRequest = pendingRequests.get(url);
if (existingRequest) {
return existingRequest as Promise<T>;
}
const request = fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
})
.finally(() => {
pendingRequests.delete(url);
});
pendingRequests.set(url, request);
return request;
}
When several components call fetchOnce with the same URL, only one network request is made. Every caller receives the result of the same promise.
Libraries such as TanStack Query, SWR, and Apollo Client already provide forms of request deduplication through their caching systems.
Why it matters
Request deduplication can:
Reduce duplicate API calls
Lower backend load
Prevent inconsistent responses
Improve performance on slow networks
Simplify coordination between components
However, deduplication depends on a reliable request identity. Two requests should only be combined when their URL, parameters, authentication context, and relevant headers are equivalent.
A request for page one should not be treated as identical to a request for page two. Likewise, requests from different authenticated users must never share data.
2. Optimistic Updates
Most interfaces wait for the server before updating the screen.
A user clicks a like button, the application sends a request, the server processes it, and the interface changes only after the response arrives.
Even when the server responds quickly, this delay can make the interface feel sluggish.
Optimistic updates reverse the order. The interface updates immediately based on the assumption that the request will succeed.
async function likePost(postId: string) {
const previousPost = getPost(postId);
updatePost(postId, {
...previousPost,
liked: true,
likes: previousPost.likes + 1,
});
try {
await api.likePost(postId);
} catch (error) {
updatePost(postId, previousPost);
showError("Unable to like this post.");
}
}
The user sees the result instantly. If the request succeeds, nothing else needs to happen. If it fails, the application restores the previous state and explains the problem.
This pattern works well for actions such as:
Liking a post
Marking a task as complete
Changing a reaction
Reordering items
Updating a simple preference
Sending a chat message
Optimistic updates are controlled assumptions
An optimistic update is not simply changing the interface early. It requires a recovery strategy.
Before updating the state, the application should keep enough information to undo the change. This may be a copy of the previous value, a cache snapshot, or an inverse operation.
The interface should also handle conflicts. Imagine that a user edits a record while another user modifies the same record on the server. The local assumption may no longer match the authoritative state.
After the mutation completes, the frontend should often refetch or invalidate the affected query.
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async updatedTodo => {
await queryClient.cancelQueries({
queryKey: ["todos"],
});
const previousTodos = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], oldTodos =>
updateTodoInList(oldTodos, updatedTodo),
);
return { previousTodos };
},
onError: (_error, _todo, context) => {
queryClient.setQueryData(
["todos"],
context?.previousTodos,
);
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["todos"],
});
},
});
Optimistic updates are most effective when failure is uncommon and the operation is easy to reverse.
They should be used more carefully for payments, irreversible actions, inventory reservations, security settings, and other operations where showing success too early could mislead the user.
3. Streaming UI
Traditional web responses often follow an all or nothing model.
The browser sends a request, waits for the server to finish all its work, downloads the complete response, and then displays the result.
Streaming UI allows the server to send useful parts of the interface as soon as they are ready.
Instead of waiting for the entire page, the user may first receive the layout, navigation, and loading placeholders. Product details can appear next, followed by reviews and recommendations.
This is especially useful when different sections depend on services with different response times.
In React, Suspense can be used to divide a page into independently loading sections.
export default function ProductPage() {
return (
<main>
<ProductSummary />
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<ProductRecommendations />
</Suspense>
</main>
);
}
The faster sections do not need to wait for the slower ones.
Streaming is also becoming essential in applications powered by generative AI. A generated response may take several seconds to complete, but the server can send tokens or structured UI fragments as they become available.
const response = await fetch("/api/generate");
if (!response.body) {
throw new Error("Streaming is not supported.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let content = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
content += decoder.decode(value, { stream: true });
renderContent(content);
}
Streaming improves perceived performance
Streaming does not always reduce the total amount of work. Its main advantage is that the user can begin seeing and using the application earlier.
A page that finishes in three seconds but displays meaningful content after half a second often feels faster than a page that displays nothing for two seconds and then appears all at once.
Good streaming interfaces should avoid excessive layout movement. Stable skeletons, reserved space, and predictable loading boundaries help prevent the page from jumping whenever new content arrives.
4. Stale While Revalidate
Caching creates a common frontend dilemma.
If cached data is displayed without checking the server, the interface may show outdated information. If every visit waits for a fresh request, the user repeatedly sees loading screens.
Stale while revalidate provides a practical middle ground.
The application immediately displays cached data, even when it may be slightly outdated. At the same time, it requests the latest version in the background. When the request finishes, the interface updates with the fresh data.
The basic flow is:
Read the cached value
Display it immediately
Request the latest value
Replace the cached value when the response arrives
With TanStack Query, this behavior can be configured using cache freshness settings.
const productsQuery = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 60_000,
});
With SWR, the pattern is built directly into the library.
const { data, error, isLoading } = useSWR(
"/api/products",
fetcher,
);
When cached data is available, the user can continue interacting with the page while revalidation happens quietly.
Freshness is a product decision
Not all data needs the same freshness policy.
A blog article can remain cached for several minutes. A user profile may be refreshed whenever the application regains focus. Available inventory may need to be refreshed more frequently. A financial transaction should usually require stronger consistency.
The correct cache duration depends on:
How often the data changes
How harmful stale information would be
How expensive the request is
Whether the user is actively viewing the data
Whether the application has another update mechanism
Stale while revalidate works best when temporarily displaying older data is better than displaying an empty screen.
5. Smart Polling
Some applications need information that changes after the initial page load.
Examples include:
Order status
Build progress
Background job completion
Delivery tracking
Payment confirmation
Processing reports
Notification counts
The simplest solution is polling, where the application sends a request every few seconds.
setInterval(() => {
fetchJobStatus();
}, 5000);
This works, but it is rarely efficient.
It continues running when the browser tab is hidden. It may keep polling after the job is complete. It does not adapt to failures or slow connections. It may also send another request before the previous request has finished.
Smart polling adjusts its behavior based on the application state.
async function pollJob(jobId: string) {
let delay = 2000;
const maximumDelay = 30_000;
while (true) {
if (document.hidden) {
await wait(5000);
continue;
}
try {
const job = await fetchJob(jobId);
if (job.status === "completed") {
showCompletedJob(job);
return;
}
if (job.status === "failed") {
showFailedJob(job);
return;
}
delay = 2000;
} catch {
delay = Math.min(delay * 2, maximumDelay);
}
await wait(delay);
}
}
function wait(milliseconds: number) {
return new Promise(resolve => {
setTimeout(resolve, milliseconds);
});
}
A smarter polling strategy may:
Stop when the operation completes
Pause when the tab is hidden
Resume when the user returns
Increase the delay after failures
Prevent overlapping requests
Poll faster during active processing
Poll slower when changes are unlikely
Stop when the component unmounts
Respect offline status
This reduces unnecessary requests while still keeping the interface reasonably fresh.
Polling is not always the final answer
Polling is useful because it is simple and works with ordinary HTTP infrastructure. However, it may not be the best solution for highly interactive or real time systems.
WebSockets, Server Sent Events, and push notifications may be more suitable when updates are frequent or need to arrive immediately.
Still, smart polling is often the right engineering tradeoff. It is easier to implement, observe, scale, and recover than a permanent real time connection.
How These Patterns Work Together
These techniques are not competing alternatives. A well designed frontend may use all of them in the same workflow.
Consider an order tracking page.
The application first displays cached order information using stale while revalidate. If several components request the order, request deduplication ensures that only one network call is sent. When the user changes the delivery instructions, an optimistic update reflects the change immediately. A slow recommendations section arrives through streaming UI. Smart polling checks the order status until the delivery is complete.
Each pattern handles a different part of the experience:
Pattern | Primary purpose |
|---|---|
Request deduplication | Avoid repeated network calls |
Optimistic updates | Make user actions feel immediate |
Streaming UI | Display useful content earlier |
Stale while revalidate | Combine fast cached data with background freshness |
Smart polling | Keep changing data updated efficiently |
The Real Goal Is Better Feedback
Users do not experience performance as a single number. They experience it through feedback.
When they click a button, something should happen immediately. When data already exists, the application should not replace it with a blank loading screen. When part of a page is ready, it should not be blocked by a slower section. When information changes over time, the interface should update without wasting resources.
These five patterns help frontends communicate progress, preserve continuity, and reduce unnecessary waiting.
The fastest application is not always the one that completes every operation first. Often, it is the one that gives the user the right information at the right moment.
Stay in the loop
Get notified when new posts are published. No spam, unsubscribe anytime.
No spam · Unsubscribe anytime