Why Building a Fast Website Is Harder Than It Looks
A practical guide to JavaScript, rendering strategies, Core Web Vitals, and frontend performance optimization.
Building a website that works is relatively easy. Building one that loads quickly, responds smoothly, and delivers a consistent experience across different devices and network conditions is a different challenge.
In frontend development, we often encounter a familiar situation: the application works, every feature behaves as expected, and the design looks right. Yet the page takes too long to load, buttons occasionally respond slowly, and users have to wait before the interface becomes usable.
Why does this happen?
The problem is that functionality and performance are two different concerns. A website that works perfectly on a developer's machine will not necessarily deliver the same experience to real users.
Modern frontend performance depends on many factors: JavaScript bundle size, rendering strategies, network requests, images, browser execution, and even architectural decisions made early in a project.
In this article, we'll explore why building a fast website is harder than it looks, which mistakes developers commonly make, and how to improve performance using measurable, practical techniques.
1. A Website Can Work Perfectly and Still Be Slow
Imagine we've built an online store using Next.js. The product page loads, search works, the shopping cart behaves correctly, and every button performs its intended action.
However, when users open the page, they see an empty area for several seconds. Filtering products occasionally freezes the interface. On mobile devices, the layout sometimes shifts unexpectedly.
From a functional perspective, everything might be correct. The user experience, however, is far from ideal.
Performance is not simply the time it takes for a page to finish loading. We also need to understand:
How quickly can users see the main content?
How quickly does the page respond to their interactions?
Does the layout remain stable while resources load?
How many resources does the application need to run?
How does the website behave on slower networks and less powerful devices?
Answering these questions requires measuring more than how the application performs on our own computers. We need to understand what happens for real users.
2. JavaScript Is More Expensive Than You Think
JavaScript is fundamental to modern frontend applications, but using it comes with a cost.
When a browser receives a JavaScript file, downloading it is only part of the process. The browser must parse the code, potentially compile it, and execute it. This work can place significant pressure on the CPU, particularly on less powerful devices.
Imagine a page containing dozens of components, several large libraries, and multiple interactive features. If everything is included in the initial JavaScript bundle, users may have to download and process code they don't need for the initial view.
The Problem With Loading Everything at Once
Suppose our dashboard contains an analytics section that users open only when they need to inspect a report.
If the charting library is included in the initial bundle, it can increase the amount of JavaScript downloaded and processed before the user even opens the chart.
One possible solution is dynamic imports.
"use client";
import dynamic from "next/dynamic";
const AnalyticsChart = dynamic(
() => import("./analytics-chart"),
{
loading: () => <p>Loading chart...</p>,
}
);
export function AnalyticsSection() {
return <AnalyticsChart />;
}This allows the component's code to be loaded separately rather than being included in the initial JavaScript bundle.
However, the actual result depends on when the component is rendered and how the application is structured.
If the chart appears immediately on every page visit, using dynamic() alone does not guarantee that its code will be loaded later. If the chart is only needed after a button click, the import can instead be triggered by that interaction.
The goal isn't to achieve the smallest possible bundle at any cost. It's to deliver the code the browser needs for the user's current task as efficiently as possible.
Does More Code Always Mean a Slower Website?
Not necessarily. An application with relatively little code can still be slow if it is poorly structured.
What matters is not just the total amount of JavaScript, but also:
How much code is required to display the initial page.
How long parsing and execution take.
How many unnecessary renders occur.
How much work is performed on the main thread.
How much JavaScript each page actually needs.
This is why bundle analyzers, browser performance profiles, and real measurements are more useful than optimizing based on bundle size alone.
3. Your Rendering Strategy Can Change Everything
One of the most important architectural decisions in React and Next.js projects is how and when HTML is generated and data is retrieved.
Common approaches include:
CSR (Client-Side Rendering): The main interface is constructed in the browser using JavaScript.
SSR (Server-Side Rendering): HTML is generated on the server during a request.
SSG (Static Site Generation): HTML is generated ahead of time during the build.
ISR (Incremental Static Regeneration): Static pages can be regenerated according to a defined strategy.
None of these approaches is universally superior.
Client-Side Rendering
CSR can be a good fit for interactive dashboards and authenticated applications where users spend time working with data.
However, if a public page depends on downloading JavaScript, executing it, and completing an API request before its main content becomes visible, users may wait longer for meaningful content.
Server-Side Rendering
SSR allows the server to generate HTML and send it to the browser. This can improve how quickly users see the main content, but it does not automatically guarantee a fast website.
If the server responds slowly, data requests take too long, or rendering requires excessive work, the initial response can still be delayed.
The Next.js App Router
In the Next.js App Router, Server Components allow certain components to be rendered on the server without including their component code in the client-side JavaScript bundle.
For example:
// app/products/page.tsx
import { getProducts } from "@/lib/products";
import { ProductList } from "@/components/product-list";
export default async function ProductsPage() {
const products = await getProducts();
return (
<main>
<h1>Products</h1>
<ProductList products={products} />
</main>
);
}In this example, the page can retrieve its data on the server. If ProductList is also a Server Component, its component code does not need to become part of the client-side JavaScript bundle.
However, if the product list requires interactive filtering, direct shopping-cart interactions, or other browser-side behavior, some parts may need Client Components.
This is where choosing the right component boundaries matters. Making an entire page a Client Component just because one small section is interactive can introduce unnecessary client-side JavaScript.
A good approach is to keep interactivity where it is actually needed.
At the same time, Server Components do not guarantee better performance in every situation. Sequential data fetching, slow backend services, and poorly designed caching strategies can still create bottlenecks.
4. Images, Fonts, and Network Requests
Developers often focus heavily on JavaScript while overlooking other resources that can significantly affect loading performance.
Image Optimization
Imagine a page with a large hero image occupying most of the initial viewport.
If the image is unnecessarily large, uses an inefficient format, or is discovered too late by the browser, the main content may take longer to appear.
In Next.js, next/image helps manage image dimensions, responsive delivery, and loading behavior.
import Image from "next/image";
export function Hero() {
return (
<section>
<Image
src="/images/hero.webp"
alt="Product dashboard"
width={1200}
height={700}
priority
/>
</section>
);
}The priority property is appropriate for an image that is important to the initial view, such as a prominent hero image. It should not be applied indiscriminately to every image.
Other important practices include:
Using dimensions appropriate for the rendered image.
Choosing efficient formats such as WebP or AVIF when suitable.
Lazy-loading images that appear farther down the page.
Providing image dimensions so the browser can reserve space before the resource loads.
Fonts
External font files can delay text rendering or cause visible font changes while the page loads.
Load only the font weights and character subsets you actually need, and avoid downloading unnecessary font files.
Next.js provides next/font, which can help manage font loading and serve fonts through locally hosted resources.
Network Requests
Another common problem is inefficient API request orchestration.
Suppose a page fetches user information first and only then requests the user's orders, even though the two requests are independent.
The requests may run sequentially when they could run concurrently.
const [user, orders] = await Promise.all([
getUser(),
getOrders(),
]);This is appropriate when the requests are independent. If the second request requires the first request's result, they cannot simply be executed concurrently without changing the data flow.
We also need to be careful with Promise.all(): if one promise rejects, the entire operation rejects. If each request should be handled independently, Promise.allSettled() or separate error handling may be more appropriate.
From a performance perspective, reducing the number of requests is not enough. Their duration, dependency order, caching behavior, and response sizes all matter.
5. Core Web Vitals: How Do We Measure User Experience?
Improving website performance starts with understanding what we are measuring.
Google's Core Web Vitals include three key metrics.
MetricWhat it measuresGood thresholdLCP (Largest Contentful Paint)How quickly the largest main content element appears≤ 2.5 secondsINP (Interaction to Next Paint)How quickly the page responds to user interactions≤ 200 msCLS (Cumulative Layout Shift)Unexpected movement of page elements≤ 0.1
These thresholds are evaluated at the 75th percentile of real-user measurements, separately for mobile and desktop experiences.
LCP: When Does the Main Content Appear?
LCP can be affected by server response time, the download of the main image, render-blocking resources, and other factors.
If LCP is poor, we need to determine whether the bottleneck is the server response, image delivery, or the browser's rendering process.
INP: Does the Page Respond Quickly?
Imagine a user clicking a filter button and waiting noticeably before seeing the results.
The cause could be a long-running JavaScript task, an expensive calculation, or too many elements being updated simultaneously.
Increasing network speed alone will not solve this problem. We need to inspect the browser's main-thread activity and reduce expensive work during interactions.
CLS: Why Does the Layout Shift While Loading?
Most developers have experienced clicking the wrong element because the page layout suddenly moved.
This can happen when image dimensions are missing, late-loading content takes up space, or dynamic elements appear above existing content.
Providing image dimensions, reserving space for expected content, and designing a stable layout can help reduce CLS.
You can investigate these metrics using PageSpeed Insights. Chrome DevTools can help identify the underlying causes of specific performance problems.
It's important to distinguish laboratory measurements from real-user data. A good Lighthouse score is useful, but it does not prove that every user has a good experience.
6. Common Performance Optimization Mistakes
When trying to improve performance, developers sometimes introduce additional complexity without achieving meaningful improvements.
Mistake 1: Using useMemo Everywhere
React's useMemo can help avoid repeating expensive calculations when its conditions are appropriate.
However, not every value needs to be memoized.
const fullName = `${firstName} ${lastName}`;Memoization is generally unnecessary for a simple expression like this.
const fullName = useMemo(
() => `${firstName} ${lastName}`,
[firstName, lastName]
);The second version introduces more code without providing a meaningful benefit in most cases.
Optimization should be based on measurements. If a calculation is inexpensive or a component rarely re-renders, memoization may not produce a noticeable improvement.
Mistake 2: Lazy-Loading Everything
Lazy loading is useful when a resource or component is not immediately needed.
However, delaying the main content or an important above-the-fold image can hurt the user experience.
We need to distinguish what must be available immediately from what can safely load later.
Mistake 3: Treating Caching as a Magic Solution
Caching can significantly reduce repeated request times and server load. However, a poorly designed caching strategy can serve stale data or introduce data consistency problems.
In Next.js, developers need to understand which data is cached, under what conditions, and when it is refreshed. We should not assume that every fetch() request is cached in the same way regardless of framework version and configuration.
Mistake 4: Trusting Lighthouse Scores Alone
A Lighthouse score of 100 feels great, but it should not become the only objective.
An application may score well in a controlled test environment and still perform poorly for users with slower devices, slow backend responses, or more demanding usage patterns.
A better approach combines Lighthouse, Chrome DevTools, bundle analysis, and real-user Core Web Vitals data.
Mistake 5: Optimizing Without Understanding the Problem
When a page is slow, it's tempting to start rewriting components immediately. Without measurements, however, we may optimize the wrong part of the application.
If the main delay comes from the server response, reducing React renders may have little effect.
If a large image is responsible for a poor LCP, reducing the JavaScript bundle may not solve the primary problem.
Measure first. Identify the bottleneck. Then change the code.
7. A Practical Checklist Before Shipping to Production
Here are the questions worth asking before deploying a real project.
Loading Performance
Does the initial page load only the JavaScript it needs?
Are large libraries loaded only when necessary?
Is the main image appropriately sized and formatted?
Are server responses and API requests sufficiently fast?
Can independent requests run concurrently?
React and Next.js
Are Client Components used only where interactivity is needed?
Are there unnecessary renders or expensive calculations?
Is data fetching organized efficiently?
Does the caching strategy match the application's freshness requirements?
Is the main page content available without unnecessary client-side work?
User Experience
Does LCP meet the target threshold?
Is INP acceptable during real interactions?
Is CLS low enough to keep the layout stable?
Has the website been tested on mobile devices?
Has performance been measured under production-like conditions?
This checklist is not a guarantee of perfect performance, but it provides a systematic way to identify common problems before they affect users.
Conclusion
Building a fast website is not about choosing one technology or applying a handful of optimization tricks. It is the result of many decisions, from application architecture to image delivery and browser main-thread activity.
React, Next.js, and modern development tools provide powerful capabilities, but they do not automatically produce fast websites. Developers still need to understand when to use server rendering, when client-side interactivity is necessary, which data should be cached, and what is actually slowing a page down.
In my opinion, one of the most important frontend development skills is not simply writing code that works. It is being able to understand why that code behaves the way it does, what its performance costs are, and how to improve it without introducing unnecessary complexity.
Good performance is not accidental. It comes from sound decisions, measurement, and continuous improvement.
Most importantly, don't optimize simply because you can. Optimize when you understand the problem, know what result you expect, and can measure whether your solution actually made a difference.