Loading post
Sep 04, 2026

Nothing on my personal site was obviously broken. It just felt a little slow. The home page took a beat to settle, and clicking a post did not feel as immediate as opening an article on Medium. That vague discomfort led me to audit all 130 public URLs on meetyudai.com, plus 87 concrete routes in a local production build. I followed the delay across images, data fetching, React Server Components, hydration, authentication, internationalization, and mobile layouts. After the changes shipped, the home page went from roughly 3.4 seconds to a 0.462-second median, while a click from the blog index to an article went from roughly 3.3 seconds to 0.520 seconds. This is what was slow, what actually helped, and why I stopped even though the site could still be made faster. 日本語版: blog-nextjs-site-performance-audit.md
This kind of performance problem is surprisingly awkward to diagnose.
If a page stays blank for five seconds, everyone agrees that it is slow. If an API returns a 500, the investigation has a clear starting point. My site did neither. Pages opened, images appeared, and links worked. There was simply an extra beat between clicking and seeing the next page. It was most noticeable on article pages, where the same action felt more immediate on reading-focused sites such as Medium.
My first guess was images. Images were indeed a serious part of the problem, but they were not the whole problem. The delay was the sum of several smaller waits across the request and rendering path.
The reader experiences one symptom—“my click did not respond”—while the system spends time in the server, network, React, image pipeline, and authentication layer.
Performance work needs a measurement contract before it needs code. Without one, it is too easy to select whichever number makes a change look successful. I split the verification into three parts:
| Action | Before | Median after | Waiting-time reduction | Speed ratio |
|---|---|---|---|---|
| Initial home display | ~3.4s | 0.462s | 86.4% | ~7.4× |
| Blog index to article | ~3.3s | 0.520s | 84.2% | ~6.3× |
Here are the five post-deployment samples rather than only the best result:
| Environment | Metric | Five samples | Median |
|---|---|---|---|
| Desktop-like | Home display | 762 / 348 / 440 / 462 / 514ms | 462ms |
| Desktop-like | Article click | 443 / 548 / 449 / 523 / 520ms | 520ms |
| Pixel 5 emulation | Home display | 605 / 419 / 508 / 697 / 391ms | 508ms |
| Pixel 5 emulation | Article click | 538 / 541 / 545 / 527 / 540ms | 540ms |
This was not a controlled, simultaneous A/B test between old and new deployments. The “before” numbers were single observations during the original diagnosis, while the “after” numbers came from repeated measurements after deployment. I therefore do not claim that every visitor will see exactly a 7.4× improvement. The defensible conclusion is broader: a wait that was in the three-second range moved to roughly half a second in these production observations.
| Page | Median of 8 | Average | Minimum | Maximum |
|---|---|---|---|---|
| Home | 283ms | 477ms | 198ms | 1,209ms |
| Blog index | 258ms | 273ms | 201ms | 455ms |
| Article detail | 373ms | 369ms | 303ms | 462ms |
| Projects | 349ms | 373ms | 214ms | 768ms |
The medians are now healthy for my use case, but the 1.2-second home-page outlier matters. It matches a structural fact I will return to later: these pages are still dynamically rendered.
The home page contains a profile, recent posts, projects, work history, education, game configuration, and a résumé link. Too much of that work used to begin after the initial HTML reached the browser, producing a cluster of requests and rerenders during the period when the page should have been becoming interactive.
I moved suitable public reads to the server, ran independent calls in parallel, and passed the results into the first render as initial data. I also deduplicated repeated reads within the same request or a short interval and skipped authentication-token work that anonymous visitors did not need.
“Move it to the server” is not sufficient advice by itself. Moving a serial waterfall from the browser to the server only relocates the waiting. The useful combination was to parallelize independent reads, remove duplicate work, and make the results available to the first render.
A blog index needs titles, summaries, categories, dates, and cover images. It does not need the complete body of every article.
Some of my previous data paths still sent body and translation data that the listing never rendered. Five sample posts serialized to about 257KB. A summary-oriented shape reduced that to about 5.3KB.
| Client payload | Before | After | Reduction |
|---|---|---|---|
| Five-post listing | 257KB | 5.3KB | 97.9% |
| Article detail | 305KB | 164KB | 46.2% |
On article pages, I stopped embedding both the Japanese and English bodies in the same client payload. The server now sends only the language being displayed. Switching languages navigates to the corresponding route and fetches that version. This trades one fetch during an explicit language switch for less transfer and parsing during the much more common single-language read.
Before tuning compression, I removed data that should never have crossed the network. Do not send it was a much stronger optimization than send it slightly more efficiently.
If a route begins fetching only after the reader clicks an article card, the browser has nothing useful to show during the server round trip. That gap feels like an ignored interaction.
I enabled prefetching for likely next destinations—the leading article cards and related-post links—and added route-level loading.tsx UI. Next.js documents Link prefetching, loading UI, and streaming as complementary tools for dynamic routes: move likely work before the click, then provide immediate visual feedback for any work that remains.
Prefetching is not free. Fully fetching every visible link would spend bandwidth and server work on pages that might never be opened. I targeted links with a high probability of being clicked rather than treating the entire site map as equally urgent.
Reference: Next.js — Linking and Navigating
Lazy loading was necessary, but lazy loading alone does not reduce file size. A two-megabyte image loaded later is still a two-megabyte image.
I restored the blog listing to Next.js Image Optimization and supplied responsive sizing information so the browser could select a suitable srcset candidate. One real image produced this result:
| Image | Transferred size |
|---|---|
| Original PNG | 2,193,208 bytes |
| 640px WebP | 23,438 bytes |
| Reduction | about 98.9% |
I applied lazy loading to below-the-fold listing images, game slides, Markdown content images, and legacy HTML body images. I deliberately kept likely LCP hero images eager or high-priority; delaying the most important above-the-fold image would improve one resource graph while making the page feel slower.
Next.js's Image component can generate device-appropriate sizes and modern formats and uses native browser lazy loading by default. Supplying an accurate sizes value helps the browser avoid downloading an unnecessarily large candidate for a small rendered area.
References: Next.js — Image Optimization, Next.js — Image Component
The first local transformation took about 1.6 seconds; a cached response took about 0.002 seconds. Image optimization does not abolish processing. It performs a transformation for a new size and format, then relies on caching to amortize that cost. It also consumes image-optimization capacity on the hosting platform.
For this site, paying that first-conversion cost and subsequently serving 23KB was clearly preferable to repeatedly transferring 2.19MB.
A fast network response does not guarantee a responsive page if the main thread is busy.
The audit found that a shared i18n instance could produce different initial state on the server and client, causing React hydration error #418 on several public pages. I changed i18n initialization to use request- and provider-scoped instances so the server markup and initial client state agreed.
I also moved page-view logging to idle time, limited AOS CSS and initialization to the home page that used it, and separated anonymous startup work from tasks that only matter after authentication.
None of these changes alone explains a sixfold improvement. Together, they stopped secondary work from clustering in the exact window when the browser needed to paint and become interactive.
The first 320px crawl of 87 routes found three pages wider than their viewport:
| Page | Viewport | Page width before | Cause |
|---|---|---|---|
| Roulette | 320px | 397px | Fixed wheel and betting-table dimensions |
| Kuizu custom new | 320px | 363px | Horizontal action layout |
| Settli new | 320px | 322px | Button arrangement at narrow widths |
I made the roulette wheel responsive while keeping the dense betting surface horizontally scrollable inside its own region. Shrinking the entire table until its labels became unreadable would have passed an overflow assertion while failing the user. Kuizu and Settli now stack the relevant controls on mobile.
A second pass after authentication initialization found a more subtle issue: the Flashcards page called protected APIs even for a signed-out visitor, exposed an Unauthorized message, and expanded to 351px. The fix was to wait for auth initialization, render a sign-in link with the correct return path, and disable the flashcard and deck requests while signed out.
The goal is not to display an error quickly. It is to display the correct state quickly.
| Verification | Scope | Result |
|---|---|---|
| Public HTTP crawl | 130 sitemap URLs | 130/130 returned 200 |
| Public mobile crawl | 130 URLs | Body content rendered on every URL |
| Local desktop crawl | 87 routes at 1280px | 0 runtime errors, 0 horizontal overflows |
| Local mobile crawl | 87 routes at 320px | 0 runtime errors, 0 horizontal overflows |
| Delayed auth-state recheck | 14 routes | 0 blank pages, auth-error leaks, or overflows |
| Unit tests | Vitest | 394 files, 5,288 passed, 1 skipped |
| End-to-end tests | Playwright | 21/21 passed |
| Pull-request checks | CI / Security / Vercel | 15/15 successful |
The work was reviewed in PR #755. I addressed three valid review findings, merged it with a regular merge commit, waited for the production Vercel deployment, and then repeated the public measurements.
It is easy to manufacture a faster score by removing content. I kept the intended content and visual quality, but the work still involved explicit tradeoffs:
The honest statement is not “there were no sacrifices.” It is: I chose what to protect and made the accepted costs visible.
The production build still classifies most public pages as dynamically rendered. The root layout reads cookies() and headers() for language and session decisions, which keeps a request-time server step in the critical path.
Moving language fully into the URL and moving authentication behind a client boundary could make public HTML and RSC output easier to cache at the CDN edge. Next.js distinguishes prerendered output, which can be cached and served from a CDN, from dynamically rendered output that includes private request-time behavior.
References: Next.js — Linking and Navigating, Next.js — Self-Hosting and CDN caching
Why not do that immediately?
The first round took a three-second wait down to roughly half a second. The next architecture change might shave another few hundred milliseconds from the median and reduce slow outliers such as the 1.2-second home response. It would also require coordinated decisions about URLs, canonical links, hreflang, language switching, authentication boundaries, and cache invalidation. The implementation and SEO risk are much larger relative to the remaining visible gain.
That does not mean the site is perfectly optimized. It means the remaining optimization is no longer justified without evidence that real users need it.
My trigger for reopening the work is now field data:
Google evaluates Core Web Vitals at the 75th percentile and defines the “good” thresholds as LCP at or below 2.5 seconds, INP at or below 200 milliseconds, and CLS at or below 0.1. The point of p75 is to include a meaningful share of slower real experiences instead of optimizing only for one fast development machine.
References: web.dev — Core Web Vitals thresholds, Vercel — Speed Insights
Images mattered, but server wait, RSC payload size, hydration, authentication, and analytics accumulated around them. Performance was a property of the entire path, not a single score or asset.
Compressing 257KB more aggressively could not compete with changing the listing contract to 5.3KB. The first question was not “How can I send this faster?” but “Why am I sending this at all?”
Prefetching moves useful work before the click. Loading UI acknowledges the interaction while remaining work completes. One reduces actual waiting on likely paths; the other prevents the remaining wait from feeling like a broken click.
Horizontal overflow, signed-out API calls, and hydration errors can hide outside the handful of pages usually chosen for Lighthouse. Crawling the route set under consistent conditions made the site visible as one product rather than a collection of individually tested pages.
Moving from 3.4 seconds to 0.46 seconds is a large user-facing gain. Moving from 0.46 seconds to something a few hundred milliseconds lower may or may not matter. The answer should come from field data. Deciding what evidence will restart the work is part of performance engineering too.
This project moved my personal site from “it works, but every click takes a beat” to “ordinary navigation feels almost immediate.”
The result did not require a dramatic rewrite. Fetch independent data in parallel. Do not fetch the same thing twice. Do not ship article bodies to a listing that never displays them. Prepare likely destinations before the click. Send images sized for the screen and delay only the ones below the fold. Eliminate hydration errors and move secondary startup work out of the way. Then test the entire route set on mobile, not just the home page on a laptop.
Together, those changes reduced the observed home and article-navigation waits by roughly 84–86%.
Static/ISR delivery through a CDN remains a powerful option, but I am deliberately keeping it in reserve. The next step is to collect real-user data and optimize again only if a real audience—not my instinct—shows where the remaining delay matters.
engineering-practicesnextjs, web-performance, performance-audit, image-optimization, core-web-vitalshow-i-made-my-nextjs-site-6x-faster