I tried to improve DevelopersIO from a performance perspective

I tried to improve DevelopersIO from a performance perspective

I worked on improving Core Web Vitals for DevelopersIO and enhanced the performance and accessibility scores in Lighthouse. I'll introduce specific measures such as LCP optimization, CLS reduction, image optimization, and bundle size reduction.
2025.10.09

This page has been translated by machine translation. View original

I'm progressively making improvements to DevelopersIO. Sometimes I fail and learn from it. As a developer, I always want to create a media platform where writers can comfortably share their experiences and readers can enjoy consuming that content.
I strive daily to make the blog useful for work, finding ideas, and solving niche problems.

While it's difficult to define what makes a site comfortable to use, improving Core Web Vitals certainly has a positive impact. As engineers, having clear metrics to aim for makes adjustments worthwhile. Here, I've summarized the changes we made to improve our site.

Refresher on Core Web Vitals

Web.dev explains it like this: Core Web Vitals are the primary metrics among those that quantify the experience provided by websites. Think of them as a health check for your website.

Web Vitals is a Google initiative that provides unified guidance for quality signals that are essential to delivering a great user experience on the web.
...
Site owners shouldn't have to be performance experts to understand the quality of experience they are delivering to their users. The Web Vitals initiative aims to simplify the landscape and help sites focus on the metrics that matter most, the Core Web Vitals.
https://web.dev/articles/vitals?hl=ja

One of the important aspects of websites is allowing users to browse comfortably. Slow loading times or unstable screens can cause users to leave, regardless of how good your content is.
Additionally, Core Web Vitals affect Google search rankings. Better metrics increase the likelihood of appearing higher in search results, making Core Web Vitals improvement an important initiative.

LCP (Largest Contentful Paint)

This metric measures the time it takes for the main content to display. Images, videos, or text blocks that appear prominently when opening a browser qualify as LCP elements. Good performance is achieved when these elements render within 2.5 seconds.

Normally, it's recommended to specify lazy-loading for img elements.
However, LCP elements are an exception. Disabling lazy-loading and setting fetchpriority to high can reduce rendering time.

CLS (Cumulative Layout Shift)

Have you ever experienced reading an article when images or videos load and shift the position of what you were reading, or move the spot you were about to click? That's CLS. It's calculated based on the affected area and distance of the shift, with 0.1 or less being the ideal value.
Elements whose dimensions are determined dynamically—primarily images, videos, and iframes—are the main contributors.

When width/height is known

Specifying these attributes prevents layout shifts in most cases by:

  • Reserving display space with these values when width or height is not specified in CSS
  • When using CSS like width: 100%; height: auto;, calculating the aspect ratio from the original image's width/height to reserve display space

When width/height is unknown

Specify aspect-ratio along with width/height in CSS. This will reserve display space based on the specified aspect ratio. For images, using styling like object-fit can properly contain the content within that area.

Interaction to Next Paint (INP)

This metric represents the time between user interaction and response. However, since DevelopersIO has few user interactions like forms, I'll omit the details in this article.

Improving Web Vitals and More

Now I'll describe specific improvement methods. In practice, we ran Chrome DevTools Lighthouse and implemented changes based on those scores.
Therefore, our improvement scope includes not only Core Web Vitals but also accessibility and overall page payload optimization.

Progression of Lighthouse Results

The metrics have improved significantly, though users might not notice much difference in experience due to infrastructure optimizations like caching. Thank you always for the infrastructure optimization.

First, here's an overview of the Lighthouse results. While the performance improvement was pleasing, personally I was most delighted to achieve a perfect 100 score for accessibility. For best practices, we couldn't reach 100 points due to elements like third-party cookies that can't be changed solely on the development side. This remains a challenge for the future.

Item March September
Performance 43 74
Accessibility 85 100
Best Practices 79 96

Here are the performance details, comparing commits from early March with early September. While some metrics slightly declined, overall improvement is evident.
There's still room for improvement, but the results are now within an acceptable range.

Metric March September
First Contentful Paint 1.6 s 2.0 s
Largest Contentful Paint 23.9 s 6.4 s
Total Blocking Time 1,040 ms 120 ms
Cumulative Layout Shift 0.193 0
Speed Index 1.6 s 2.3 s

Notes on Measurement

Actual measurements were taken in a local environment using mobile settings, averaging three runs on the homepage.
Since Lighthouse performance results vary between runs, we adopted the method of taking multiple measurements and calculating the average. I recommend treating these results as reference values.

Code Change Volume

When I asked AI to analyze the code changes between commits, it reported:

Added: 265,912 characters
Deleted: 380,195 characters
Net: -114,283 characters (reduction)

223 files changed, with 7,418 lines added and 11,859 lines deleted.

It seems we actually reduced the code volume, though it felt like I was writing a lot. Note that these changes include more than just Web Vitals improvements, and performance doesn't necessarily correlate with change volume.

LCP Element Optimization

On DevelopersIO, LCP elements are all images. We're using Next.js, and specifying priority on the Image component makes it preload and considered high priority. As a result, lazy-loading is disabled and fetchpriority is set to high.

<Image src="" … priority />

This should be applied across the site. DevelopersIO pages can be categorized as:

  • Homepage
  • Article content pages
  • Featured category listing pages
  • Tag and all-articles listing pages
  • Author pages

The homepage is most unique, with LCP elements varying based on browser width:

  • With sufficient width and height: The first article image in the article list is the LCP element
  • On narrower devices like smartphones: Either the first or second article image, or the header advertisement image

Since LCP optimization doesn't need to be limited to a single element, we targeted all potential LCP elements.
For article content pages, the thumbnail image is the LCP element. This was straightforward to optimize.
For featured category listing pages, the featured category image is the LCP element. This was also straightforward.

For tag and all-articles listing pages, on smartphones, article images become the LCP element. On PC, it's the advertisement on the right. However, since this element isn't the main content and is implemented with Suspense for skeleton display, we decided not to add any attributes to it. Due to caching, the transition from skeleton might not be visible, but that's the internal implementation.

Author pages are also somewhat complex. On smartphones, a long description can become the LCP element. It could be the author's profile image or the first article image. Therefore, we optimized both the profile image and the first article.

Suppressing CLS

CLS was occurring on article pages viewed on PC and on the homepage viewed on smartphones.
For the former, the cause was that while the sidebar menu had width specified, the Image element didn't have appropriate attributes. This could be avoided by specifying width and height as shown in the commented code:

<aside className="lg:w-64">
	{promoLinks.map((link) => {
		return (
		<div key={link.title}>
			{link.link && (
			<Link target={link.openNewTab ? '_blank' : ''} href={link.link}>
				<Image
					src={link.image}
					alt={link.title}
					width={0}
			     // width={256}
					height={0}
			     // height={624}
					className="w-full h-auto object-cover"
				/>
			</Link>
		)}
		</div>
	)
})}

The CLS on the homepage was caused by the header advertisement's height not being properly determined:

<div className="overflow-x-auto">
	<div className="flex mx-auto w-fit">
		{subHeaderBannerPromoLinks.map((banner) => {
			return (
				<Link
					href={banner.link}
					key={banner.title}
					className="block flex-none w-full lg:w-auto"
				>
					<Image
						src={banner.image}
						alt={banner.title}
						width={670}
						height={280}
						className="h-auto w-full lg:h-36 lg:w-fit"
					/>
				</Link>
			)
		})}
	</div>
</div>

We changed it to:

<div className="overflow-x-auto">
	<div className="flex mx-auto w-full lg:w-fit">
		{subHeaderBannerPromoLinks.map((banner, index) => {
			return (
				<Link
					href={banner.link}
					key={banner.title}
					className={cn(
						'block flex-none h-auto w-full',
						'lg:h-36 lg:w-auto',
					)}
				>
					<Image
						src={banner.image}
						alt={banner.title}
						width={670}
						height={280}
						className="h-auto w-full lg:h-full lg:w-auto"
					/>
				</Link>
			)
		})}
	</div>
</div>

By adjusting the parent element's width and explicitly specifying the child component's width/height, we avoided the shift.

CLS can be easily prevented by specifying sizes on both parent and child elements when image dimensions are predictable.
Since this greatly improves user experience, we should address it wherever possible.

Accessibility Improvements

We also focused on accessibility improvements. While controlling user-submitted content is difficult, we made the following changes where possible:

  • For button and a elements: When containing only SVG icons, added aria-label that clearly indicates the action or destination
    • For various SNS logo links, hamburger menu, mode toggle buttons, etc.
  • Enabling proper keyboard access to all elements
    • Standard links need no special handling
    • For the hamburger menu:
      • Close with ESC key
      • Navigate with Tab key
      • Implement focus trap to prevent focus from moving to background elements
  • Always set alt attributes on img elements
    • Specify alt text for images that need to be read by screen readers
    • Use empty strings for images that don't need to be read, indicating to screen readers to skip them
      • For author icons next to author names, use empty strings since the label is provided by adjacent elements
      • Use empty strings for purely decorative images

We also improved areas with low contrast ratios across the design. Let's continue making the site accessible for everyone.

Image Optimization

Images affect all performance metrics. Even with properly set LCP elements, large source images will load slowly. Network transfer increases proportionally with image count. On DevelopersIO, CDN makes initial display fast, but heavy images delay rendering and harm user experience. This was a high-priority issue with significant room for improvement.

DevelopersIO stores images in Cloudinary, Contentful, and S3. The first two can be optimized server-side. We excluded S3 images used before 2023 from this optimization effort.

Next.js's Image component allows specifying an ImageLoader, enabling external server optimization instead of Next.js's built-in optimization. Our implementation is:

  • For hostname images.ctfassets.net (Contentful): Specify width and format via query parameters
  • For hostname devio2024-media.developers.io (Cloudinary): Specify width/format/quality in the path
  • For others: Add w query parameter to avoid Next.js warnings
'use client'

import type { ImageLoader } from 'next/image'

export const loader: ImageLoader = ({ src, width }) => {
  const url = new URL(src)
  if (url.hostname === 'images.ctfassets.net') {
    return `${src}?w=${width}&fm=webp`
  }
  if (
    url.hostname === 'devio2024-media.developers.io' &&
    url.pathname.includes('image/upload')
  ) {
    const prefix = 'image/upload'
    const id = url.pathname.split('image/upload/')[1]
    const path = `/${prefix}/f_auto,q_auto,w_${width}/${id}`

    url.pathname = path

    return url.toString()
  }

  return `${src}?w=${width}`
}

Depending on the original images, these changes significantly reduced network traffic. The performance improvement primarily resulted from this. Author images in article cards were notably improved, as they only needed to be about 20px but were downloading at full size. Reduced image transfer also provided the side benefit of optimizing CDN costs.

However, there's still room for optimization. Currently, we're not adjusting width based on device width, and article card images are fixed at a maximum of 640px. This is more than necessary for PC and smartphone display. We're considering adjustments using the Image component's sizes property. Given that we applied optimization site-wide at once, we've kept implementation simple for now while monitoring Cloudinary usage and the upstream CloudFront status.

Bundle Size Reduction

Most components on article pages were Client Components, so we replaced them with Server Components. We pushed state and side effects to the leaf components where possible. This significantly reduced the size distributed to the client side. We also had DOM operations in Client Components, specifically for the table of contents. We switched from node-html-parser to the web standard DOMParser.

While the RootLayout size increased slightly due to adding a mode toggle button and introducing next-intl, other areas saw significant improvements.

March

Route (app)                              Size     First Load JS
┌ ƒ /                                    3.09 kB         123 kB
├ ƒ /articles/[slug]                     503 kB          647 kB
├ ƒ /author/[slug]                       1.58 kB         101 kB
├ ƒ /pages/[page]                        206 B           120 kB
├ ƒ /referencecat/[slug]                 206 B           120 kB
├ ƒ /tags/[slug]                         147 B           134 kB
+ First Load JS shared by all            87.3 kB
  ├ chunks/23-9c37f8ec367d6331.js        31.6 kB
  ├ chunks/fd9d1056-05a49ed17ec6dd44.js  53.6 kB
  └ other shared chunks (total)          2.02 kB

September

Route (app)                               Size     First Load JS
┌ ƒ /                                       861 B         130 kB
├ ƒ /articles/[slug]                      2.42 kB         139 kB
├ ƒ /author/[slug]                          423 B         126 kB
├ ƒ /pages/[page]                         1.28 kB         127 kB
├ ƒ /referencecat/[slug]                  1.28 kB         127 kB
├ ƒ /tags/[slug]                          1.28 kB         127 kB
+ First Load JS shared by all              102 kB
  ├ chunks/255-5698b1ebe3a4fb99.js        45.5 kB
  ├ chunks/4bd1b696-409494caf8c83275.js   54.2 kB
  └ other shared chunks (total)           1.95 kB

Conclusion

After working to improve DevelopersIO's Core Web Vitals, our Lighthouse scores improved significantly. I'm particularly pleased that we achieved a perfect 100 for accessibility, meaning we can now deliver content comfortably to more people.

In this improvement process, we implemented various measures including LCP element optimization, CLS suppression, image optimization, and bundle size reduction. I've realized that even small changes, when accumulated, can produce significant results.
However, there's still room for improvement. We plan to continue working on image size optimization and best practices score improvement.

I hope this article proves helpful to those working on website performance improvements.
Thank you for your continued support of DevelopersIO.

Share this article

FacebookHatena blogX

Related articles