Fix LCP Issues on Contentful (Loading Speed) | OpsBlu Docs

Fix LCP Issues on Contentful (Loading Speed)

Improve Contentful LCP by using Images API transforms, reducing GraphQL payload sizes, and enabling static generation on your frontend.

Largest Contentful Paint (LCP) measures how quickly the main content of your Contentful-powered site loads. Poor LCP directly impacts SEO rankings and conversion rates.

Target: LCP under 2.5 seconds Good: Under 2.5s | Needs Improvement: 2.5-4.0s | Poor: Over 4.0s

For general LCP concepts, see the global LCP guide.

Contentful-Specific LCP Issues

1. Unoptimized Contentful Images

The most common LCP issue is large, unoptimized images from Contentful's Image API.

Problem: Hero images or featured images loading at full resolution.

Diagnosis:

  • Run PageSpeed Insights
  • Check if Contentful image is the LCP element
  • Look for "Properly size images" warning

Solutions:

A. Use Contentful Image API Parameters

Contentful's Image API supports dynamic image transformation:

// Helper function for Contentful images
export function getContentfulImageUrl(
  url: string,
  options: {
    width?: number
    height?: number
    quality?: number
    format?: 'webp' | 'jpg' | 'png'
    fit?: 'pad' | 'fill' | 'scale' | 'crop' | 'thumb'
    focus?: 'center' | 'top' | 'right' | 'left' | 'bottom' | 'top_right' | 'top_left' | 'bottom_right' | 'bottom_left' | 'face' | 'faces'
  } = {}
): string {
  const params = new URLSearchParams()

  if (options.width) params.append('w', options.width.toString())
  if (options.height) params.append('h', options.height.toString())
  if (options.quality) params.append('q', options.quality.toString())
  if (options.format) params.append('fm', options.format)
  if (options.fit) params.append('fit', options.fit)
  if (options.focus) params.append('f', options.focus)

  return `${url}?${params.toString()}`
}

// Usage in component
export function HeroImage({ asset }) {
  const imageUrl = getContentfulImageUrl(asset.fields.file.url, {
    width: 1920,
    quality: 80,
    format: 'webp',
    fit: 'fill',
  })

  return (
    <img
      src={imageUrl}
      alt={asset.fields.title}
      width={1920}
      height={1080}
      loading="eager"
    />
  )
}

B. Implement Responsive Images

Use srcset for different screen sizes:

export function ResponsiveContentfulImage({ asset, alt }) {
  const baseUrl = asset.fields.file.url

  return (
    <img
      src={getContentfulImageUrl(baseUrl, { width: 1920, format: 'webp' })}
      srcSet={`
        ${getContentfulImageUrl(baseUrl, { width: 640, format: 'webp' })} 640w,
        ${getContentfulImageUrl(baseUrl, { width: 750, format: 'webp' })} 750w,
        ${getContentfulImageUrl(baseUrl, { width: 828, format: 'webp' })} 828w,
        ${getContentfulImageUrl(baseUrl, { width: 1080, format: 'webp' })} 1080w,
        ${getContentfulImageUrl(baseUrl, { width: 1200, format: 'webp' })} 1200w,
        ${getContentfulImageUrl(baseUrl, { width: 1920, format: 'webp' })} 1920w,
      `}
      sizes="100vw"
      alt={alt || asset.fields.title}
      width={asset.fields.file.details.image.width}
      height={asset.fields.file.details.image.height}
      loading="eager"
    />
  )
}

C. Next.js Image Component

Use Next.js Image component with Contentful:

import Image from 'next/image'

// Configure Contentful domain in next.config.js
// next.config.js
module.exports = {
  images: {
    domains: ['images.ctfassets.net'],
    formats: ['image/webp'],
  },
}

// Component
export function ContentfulImage({ asset, priority = false }) {
  return (
    <Image
      src={`https:${asset.fields.file.url}`}
      alt={asset.fields.title}
      width={asset.fields.file.details.image.width}
      height={asset.fields.file.details.image.height}
      priority={priority}  // true for LCP image
      quality={80}
      placeholder="blur"
      blurDataURL={getBlurDataURL(asset)}
    />
  )
}

D. Generate Blur Placeholders

Create low-quality placeholders for better perceived performance:

// Generate blur data URL from Contentful
export function getBlurDataURL(asset: any): string {
  const tinyUrl = getContentfulImageUrl(asset.fields.file.url, {
    width: 10,
    quality: 10,
  })

  return tinyUrl
}

// Or use a library
import { getPlaiceholder } from 'plaiceholder'

export async function getStaticProps() {
  const post = await getContentfulPost()

  const { base64 } = await getPlaiceholder(
    `https:${post.fields.featuredImage.fields.file.url}`
  )

  return {
    props: {
      post,
      blurDataURL: base64,
    },
  }
}

2. Slow Contentful API Responses

Waiting for Contentful API on the client causes LCP delays.

Problem: Content fetched on client-side, blocking render.

Diagnosis:

  • Check Network tab for Contentful API calls
  • Measure time to first byte (TTFB)
  • Look for API calls blocking render

Solutions:

A. Use Static Site Generation (SSG)

Pre-render pages at build time:

Next.js:

// pages/blog/[slug].tsx
export async function getStaticProps({ params }) {
  const post = await contentfulClient.getEntries({
    content_type: 'blogPost',
    'fields.slug': params.slug,
  })

  return {
    props: {
      post: post.items[0],
    },
    revalidate: 3600, // Rebuild every hour
  }
}

export async function getStaticPaths() {
  const posts = await contentfulClient.getEntries({
    content_type: 'blogPost',
  })

  return {
    paths: posts.items.map(post => ({
      params: { slug: post.fields.slug },
    })),
    fallback: 'blocking',
  }
}

Gatsby:

// gatsby-node.js
exports.createPages = async ({ graphql, actions }) => {
  const { createPage } = actions

  const result = await graphql(`
    query {
      allContentfulBlogPost {
        nodes {
          slug
        }
      }
    }
  `)

  result.data.allContentfulBlogPost.nodes.forEach(post => {
    createPage({
      path: `/blog/${post.slug}`,
      component: require.resolve('./src/templates/blog-post.tsx'),
      context: { slug: post.slug },
    })
  })
}

B. Implement Caching

Cache Contentful responses:

// Simple in-memory cache
const cache = new Map()

export async function getCachedContentfulEntry(id: string) {
  const cacheKey = `entry_${id}`

  if (cache.has(cacheKey)) {
    return cache.get(cacheKey)
  }

  const entry = await contentfulClient.getEntry(id)
  cache.set(cacheKey, entry)

  // Clear cache after 5 minutes
  setTimeout(() => cache.delete(cacheKey), 5 * 60 * 1000)

  return entry
}

// Redis cache for production
import { createClient } from 'redis'

const redis = createClient({ url: process.env.REDIS_URL })

export async function getContentfulEntryWithRedis(id: string) {
  const cacheKey = `contentful:entry:${id}`
  const cached = await redis.get(cacheKey)

  if (cached) {
    return JSON.parse(cached)
  }

  const entry = await contentfulClient.getEntry(id)

  // Cache for 1 hour
  await redis.setEx(cacheKey, 3600, JSON.stringify(entry))

  return entry
}

C. Use GraphQL for Efficiency

GraphQL fetches only needed fields:

// With REST API - fetches everything
const entry = await contentfulClient.getEntry(id)

// With GraphQL - only what you need
const query = `
  query {
    blogPost(id: "${id}") {
      title
      slug
      publishDate
      featuredImage {
        url
        width
        height
      }
    }
  }
`

const { data } = await fetch(
  `https://graphql.contentful.com/content/v1/spaces/${SPACE_ID}`,
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${ACCESS_TOKEN}`,
    },
    body: JSON.stringify({ query }),
  }
).then(res => res.json())

3. Client-Side Rendering (CSR) Delays

CSR requires additional round trips before rendering content.

Problem: Page waits for JavaScript, then fetches content, then renders.

Diagnosis:

  • Check if content appears after JavaScript loads
  • Look for "white screen" before content
  • Measure time to interactive (TTI)

Solutions:

A. Use Server-Side Rendering (SSR)

Render content on the server:

Next.js:

// pages/blog/[slug].tsx
export async function getServerSideProps({ params }) {
  const post = await contentfulClient.getEntries({
    content_type: 'blogPost',
    'fields.slug': params.slug,
  })

  return {
    props: {
      post: post.items[0],
    },
  }
}

Nuxt:

<script>
export default {
  async asyncData({ $contentful, params }) {
    const post = await $contentful.getEntries({
      content_type: 'blogPost',
      'fields.slug': params.slug,
    })

    return {
      post: post.items[0],
    }
  },
}
</script>

B. Implement Streaming SSR

Next.js (App Router):

// app/blog/[slug]/page.tsx
import { Suspense } from 'react'

async function BlogContent({ slug }) {
  const post = await getContentfulPost(slug)
  return <article>{/* Content */}</article>
}

export default function BlogPage({ params }) {
  return (
    <Suspense fallback={<LoadingSkeleton />}>
      <BlogContent slug={params.slug} />
    </Suspense>
  )
}

4. Large Rich Text Fields

Processing large Rich Text content on the client is slow.

Problem: Rich Text rendering blocks paint.

Diagnosis:

  • Check if LCP element is in Rich Text
  • Profile Rich Text processing time
  • Look for multiple embedded entries

Solutions:

A. Process Rich Text on Server

// Server-side
import { documentToHtmlString } from '@contentful/rich-text-html-renderer'

export async function getStaticProps() {
  const post = await getContentfulPost()

  // Process on server
  const bodyHtml = documentToHtmlString(post.fields.body)

  return {
    props: {
      post,
      bodyHtml,
    },
  }
}

// Client-side
export default function BlogPost({ bodyHtml }) {
  return <div dangerouslySetInnerHTML={{ __html: bodyHtml }} />
}

B. Lazy Load Embedded Content

import { Suspense, lazy } from 'react'
import { documentToReactComponents } from '@contentful/rich-text-react-renderer'

const EmbeddedVideo = lazy(() => import('./EmbeddedVideo'))

const renderOptions = {
  renderNode: {
    [BLOCKS.EMBEDDED_ENTRY]: (node) => {
      return (
        <Suspense fallback={<div>Loading...</div>}>
          <EmbeddedVideo entry={node.data.target} />
        </Suspense>
      )
    },
  },
}

5. Web Fonts Loading

Custom fonts from Contentful can delay LCP.

Problem: Waiting for font files to load.

Diagnosis:

  • Check if text is LCP element
  • Look for font-related delays in waterfall
  • Check for FOIT (Flash of Invisible Text)

Solutions:

A. Preload Fonts

// next.config.js or app/layout.tsx
export default function RootLayout() {
  return (
    <html>
      <head>
        <link
          rel="preload"
          href="/fonts/custom-font.woff2"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
        />
      </head>
      <body>{children}</body>
    </html>
  )
}

B. Use font-display: swap

@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom-font.woff2') format('woff2');
  font-display: swap; /* Show fallback immediately */
  font-weight: 400;
  font-style: normal;
}

6. Framework-Specific Issues

Next.js Issues

Hydration Delays:

// Avoid large client-side components
'use client'

// Instead, use server components
async function ServerContent() {
  const data = await getContentfulData()
  return <div>{data}</div>
}

Bundle Size:

// next.config.js
module.exports = {
  experimental: {
    optimizeCss: true,
  },
  compiler: {
    removeConsole: process.env.NODE_ENV === 'production',
  },
}

Gatsby Issues

Build Performance:

// gatsby-config.js
module.exports = {
  plugins: [
    {
      resolve: 'gatsby-source-contentful',
      options: {
        spaceId: process.env.CONTENTFUL_SPACE_ID,
        accessToken: process.env.CONTENTFUL_ACCESS_TOKEN,
        downloadLocal: true, // Download images locally
        forceFullSync: false, // Only fetch changes
      },
    },
  ],
}

Framework-Specific Optimizations

Next.js Optimizations

Image Optimization:

import Image from 'next/image'

const contentfulLoader = ({ src, width, quality }) => {
  const url = new URL(src)
  url.searchParams.set('w', width.toString())
  url.searchParams.set('q', (quality || 75).toString())
  url.searchParams.set('fm', 'webp')
  return url.toString()
}

export function ContentfulImage({ src, alt, ...props }) {
  return (
    <Image
      loader={contentfulLoader}
      src={src}
      alt={alt}
      {...props}
    />
  )
}

Incremental Static Regeneration:

export async function getStaticProps() {
  const data = await getContentfulData()

  return {
    props: { data },
    revalidate: 60, // Rebuild every 60 seconds
  }
}

Gatsby Optimizations

Deferred Static Generation:

// gatsby-config.js
module.exports = {
  flags: {
    FAST_DEV: true,
    DEV_SSR: false,
    PRESERVE_FILE_DOWNLOAD_CACHE: true,
  },
}

Partial Hydration:

// Use gatsby-plugin-image for Contentful images
import { GatsbyImage } from 'gatsby-plugin-image'

export function ContentfulImage({ image }) {
  return (
    <GatsbyImage
      image={image.gatsbyImageData}
      alt={image.title}
      loading="eager" // For LCP image
    />
  )
}

Testing & Monitoring

Test LCP

Tools:

  1. PageSpeed Insights - Lab and field data
  2. WebPageTest - Detailed waterfall
  3. Chrome DevTools - Local testing
  4. Lighthouse CI - Automated testing

Test Different Pages:

  • Homepage
  • Blog posts
  • Product pages (if e-commerce)
  • Dynamic content pages

Test Different Devices:

  • Desktop
  • Mobile (Slow 3G, Fast 3G, 4G)
  • Tablet

Monitor LCP Over Time

Real User Monitoring:

// Track LCP with Web Vitals
import { getLCP } from 'web-vitals'

getLCP((metric) => {
  // Send to analytics
  if (window.gtag) {
    window.gtag('event', 'web_vitals', {
      event_category: 'Web Vitals',
      event_label: 'LCP',
      value: Math.round(metric.value),
      non_interaction: true,
    })
  }
})

Chrome User Experience Report (CrUX):

  • Real user data in PageSpeed Insights
  • Track trends over 28 days

Quick Wins Checklist

Priority optimizations for immediate LCP improvements:

  • Use Contentful Image API with width/quality parameters
  • Add explicit width/height to all Contentful images
  • Set loading="eager" on LCP image
  • Preload LCP image in <head>
  • Use SSG or ISR instead of CSR for Contentful content
  • Implement response caching for Contentful API
  • Use GraphQL instead of REST API
  • Optimize Rich Text processing (server-side if possible)
  • Preload critical fonts
  • Use Next.js Image component or gatsby-plugin-image
  • Enable WebP format via Contentful Image API
  • Test with PageSpeed Insights

Advanced Optimizations

Edge Caching

Cache Contentful content at the edge:

// Cloudflare Worker example
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  const cache = caches.default
  let response = await cache.match(request)

  if (!response) {
    response = await fetch(request)

    // Cache Contentful API responses for 1 hour
    if (request.url.includes('cdn.contentful.com')) {
      const newResponse = new Response(response.body, response)
      newResponse.headers.set('Cache-Control', 'max-age=3600')
      event.waitUntil(cache.put(request, newResponse.clone()))
    }
  }

  return response
}

Service Worker Caching

// service-worker.js
self.addEventListener('fetch', event => {
  if (event.request.url.includes('images.ctfassets.net')) {
    event.respondWith(
      caches.match(event.request).then(response => {
        return response || fetch(event.request).then(fetchResponse => {
          return caches.open('contentful-images').then(cache => {
            cache.put(event.request, fetchResponse.clone())
            return fetchResponse
          })
        })
      })
    )
  }
})

When to Hire a Developer

Consider hiring a developer if:

  • LCP consistently over 4 seconds after optimizations
  • Complex Contentful implementation requires refactoring
  • Need advanced caching strategies
  • Framework-specific expertise required
  • Migration to different framework needed

Next Steps

For general LCP optimization strategies, see LCP Optimization Guide.