I was building a multi-tenant application with Payload CMS when I encountered a performance bottleneck that taught me an important lesson about data fetching strategies. My pages contained various blocks - Hero sections with CTAs, Contact forms with business info, and image Galleries with potentially hundreds of photos. Initially, I followed the typical pattern of shallow queries followed by individual fetches, but the server-side rendering performance was terrible.
After diving deep into Payload's depth parameter and testing different approaches, I discovered that the "one size fits all" mentality doesn't work for SSR optimization. The key insight? Different block types require fundamentally different fetching strategies based on their data characteristics and usage patterns.
The Core Dilemma: Depth 0 vs Depth 2
Payload CMS, built on Drizzle ORM, provides a depth parameter that controls how deeply relationships are populated in your queries. This seemingly simple parameter creates a critical decision point for server-side rendering performance.
With depth: 0, you get lightweight responses containing only relationship IDs:
The question becomes: which approach serves server-side rendering better?
Why One Big Query Usually Wins for SSR
For server-side rendering, the performance characteristics strongly favor fewer, larger queries over multiple smaller ones. Here's why the math works out in favor of depth: 2:
Database Connection Overhead: Each query requires establishing a connection, authentication, query planning, and result serialization. A single query with depth: 2 performs all relationship joins in one operation, while the shallow approach multiplies this overhead across every relationship fetch.
Caching Efficiency: A complete page dataset can be cached as a single unit with a single cache key. Multiple individual fetches require managing separate cache entries, invalidation strategies, and cache coordination.
Query Optimization: Modern databases excel at join operations. A single query with multiple joins often performs better than sequential individual queries, especially when relationships involve the same underlying tables.
Consider the performance difference for a typical page:
This creates several problems. First, the initial page response becomes massive, potentially several megabytes of JSON data that must be serialized, transmitted, and parsed before any rendering can begin. Second, most gallery implementations use lazy loading where only the first 6-12 images are initially visible, making the bulk of this data immediately wasteful.
The solution is recognizing that galleries have fundamentally different usage patterns than other blocks. While a Hero block's CTAs are always needed for rendering, a Gallery's 100th image might never be viewed by the user.
Here's how I handle this distinction:
typescript
// File: src/blocks/general/Gallery/components/GalleryBlockVariant1.tsxconstGalleryBlock = ({ images }) => {
// With depth: 2, images are already Media objects, but we still lazy loadconst mediaObjects = images asMedia[]
const [loadedImages, setLoadedImages] = useState([])
const [currentIndex, setCurrentIndex] = useState(0)
useEffect(() => {
// Load only first batch from pre-fetched dataconst initialPhotos = mediaObjects.slice(0, BATCH_SIZE)
setLoadedImages(initialPhotos.map(item => ({ id: item.id, media: item })))
}, [images])
const loadMoreImages = useCallback(() => {
// Load next batch from pre-fetched data (no API calls)const nextBatch = mediaObjects.slice(currentIndex, currentIndex + BATCH_SIZE)
setLoadedImages(prev => [...prev, ...nextBatch])
setCurrentIndex(prev => prev + BATCH_SIZE)
}, [currentIndex, mediaObjects])
// Rest of lazy loading implementation
}
This hybrid approach gives you the benefits of pre-fetched data (the Media objects are complete with URLs, dimensions, and alt text) while maintaining performance through progressive disclosure.
One challenge with this approach is that Payload generates TypeScript interfaces based on schema definitions, not runtime behavior. Even with depth: 2, you'll see union types like (number | Cta)[] because the same field can contain either IDs or full objects depending on the depth parameter.
The solution is strategic type assertion. Since you control the depth parameter, you know what data structure you're receiving:
typescript
// The generated type is still a unioninterfaceHeroBlock {
ctas?: (number | Cta)[] | nullimages?: (number | Media)[] | null
}
// But you know they're populated objects at runtimeconstHeroBlockVariant2 = async (props: HeroBlock) => {
// Type assertions based on your known depthconst ctas = props.ctasasCta[] | nullconst images = props.imagesasMedia[] | null// Now TypeScript knows these are full objects
}
This pattern is standard practice in Payload applications and similar ORMs where the same field definition supports multiple depth levels.
Static Generation vs Server-Side Rendering Considerations
The fetching strategy discussion primarily applies to server-side rendering scenarios. With static site generation, the performance calculus changes significantly because all data fetching happens at build time rather than per request.
For statically generated pages, the choice between shallow and deep queries becomes less critical from a runtime performance perspective. However, the deep query approach still provides benefits in terms of build time efficiency and code simplicity, since you're eliminating the complexity of managing multiple individual fetches across your component tree.
The Gallery lazy loading pattern remains relevant even in static generation, not for server performance but for client-side bundle size and initial page load optimization.
Making the Strategic Decision
When implementing this strategy in your own application, consider these factors for each block type:
Use deep fetching (depth: 2) when:
Relationships are always needed for rendering
The data volume is reasonable (CTAs, forms, featured images)
The content is above-the-fold or critical for initial page load
You want to minimize server-side complexity
Use shallow fetching with lazy loading when:
You're dealing with large collections (image galleries, product lists)
Content might not be viewed by all users
The data volume could significantly impact initial page size
Progressive disclosure provides better user experience
The key insight is recognizing that modern SSR applications benefit from strategic over-fetching of essential data while maintaining selective loading for high-volume content. This hybrid approach gives you the performance benefits of fewer database queries without the drawbacks of massive initial payloads.
By implementing depth: 2 queries with intelligent lazy loading for Gallery blocks, I reduced my application's average page load time by 40% while maintaining optimal user experience for image-heavy content. The server load decreased significantly due to fewer database connections per request, and the simplified component logic made the codebase easier to maintain.
Let me know in the comments if you have questions about implementing this strategy in your own Payload CMS application, and subscribe for more practical development guides.