Payload CMS scales well for high-traffic applications because the expensive part, rendering pages for visitors, does not have to touch the CMS at all. With static generation or ISR in Next.js, a CDN in front, on-demand revalidation from Payload hooks, indexed database fields, lean queries and stateless containers, a single Payload deployment can serve very large traffic volumes. The CMS then only handles editor activity and cache misses.
This guide covers the architecture and the specific settings we use when running Payload CMS in production.
Understand Where the Load Actually Goes
In a typical Payload 3 project, one Next.js application contains three things: the admin panel, the API (REST, GraphQL and the Local API), and your public frontend. Scaling becomes simple once you separate the two kinds of traffic:
- Visitor traffic is read-heavy and highly cacheable. This should be served from static HTML and a CDN wherever possible.
- Editor and API write traffic is small in volume but must always hit the database. This is what your Payload server and database are actually sized for.
If every page view triggers database queries, you will hit limits quickly no matter how big the server is. If page views are served from cache, even a modest server goes a long way.
1. Serve Pages Statically and Revalidate on Change
Generate public pages at build time or on first request, then cache them. Instead of short time-based revalidation, trigger revalidation exactly when content changes using a Payload afterChange hook:
import type { CollectionAfterChangeHook } from 'payload'
import { revalidatePath, revalidateTag } from 'next/cache'
export const revalidatePost: CollectionAfterChangeHook = ({ doc, previousDoc, req }) => {
if (req.context.disableRevalidate) return doc
if (doc._status === 'published') {
revalidatePath(`/blog/${doc.slug}`)
revalidateTag('posts-list')
}
// Page was unpublished or its slug changed: clear the old path too
if (previousDoc?._status === 'published' && previousDoc.slug !== doc.slug) {
revalidatePath(`/blog/${previousDoc.slug}`)
}
return doc
}
Attach it in your collection config with hooks: { afterChange: [revalidatePost] }, and add a matching afterDelete hook. Pages stay instantly fresh for editors while visitors almost always get cached HTML. The disableRevalidate context flag lets you skip this during bulk imports.
2. Put a CDN in Front of Everything
Cache static pages, Next.js assets and media at the edge with Cloudflare, CloudFront or your host's CDN. Exclude /admin and /api from caching so editors and authenticated requests are never served stale or shared responses.
3. Index the Fields You Query
Every field you filter or sort on in production queries should be indexed. Slugs, status fields, dates and relationship fields used in where clauses are the usual candidates.
fields: [
{ name: 'slug', type: 'text', required: true, unique: true, index: true },
{ name: 'publishedAt', type: 'date', index: true },
{ name: 'category', type: 'relationship', relationTo: 'categories', index: true },
]
For multi-field queries, Payload also supports compound indexes at the collection level through the indexes property. On PostgreSQL, check slow queries with EXPLAIN ANALYZE before adding indexes blindly.
4. Keep Queries Lean: depth, select and limit
The most common Payload performance problem is over-fetching. By default, relationships are populated to a depth of 2, which can turn one query into a large tree of joined data.
const posts = await payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
sort: '-publishedAt',
limit: 12,
depth: 0,
select: { title: true, slug: true, publishedAt: true, heroImage: true },
})
depth: 0returns relationship IDs only. Increase it only where you actually render related data.selectreturns only the fields you need, which is especially important when documents contain large rich text or block fields.limitand pagination should always be explicit on listing pages. Never fetch an entire collection to render a page.- Use the Local API in server components and route handlers. It skips HTTP overhead entirely.
5. Configure Database Connection Pooling
With PostgreSQL, every running container opens its own connection pool. Multiply containers by pool size and you can exhaust the database's connection limit long before CPU becomes a problem.
import { postgresAdapter } from '@payloadcms/db-postgres'
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI,
max: 10,
},
}),
Keep max modest per instance and use PgBouncer (or your managed provider's pooler) once you run several containers or serverless functions. With MongoDB, managed clusters such as Atlas handle this well, but the same principle of predictable pool sizes applies.
6. Move Media Off the Application Server
Storing uploads on the local disk ties media to one container and makes horizontal scaling impossible. Use an official storage adapter so uploads go straight to object storage:
import { s3Storage } from '@payloadcms/storage-s3'
plugins: [
s3Storage({
collections: { media: true },
bucket: process.env.S3_BUCKET!,
config: {
region: process.env.S3_REGION,
endpoint: process.env.S3_ENDPOINT, // e.g. DigitalOcean Spaces
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
},
}),
],
Serve that bucket through a CDN, and use Payload's imageSizes on the media collection to generate resized variants once, at upload time, instead of resizing on every request.
7. Run Stateless Containers and Scale Horizontally
Once media is in object storage and sessions use JWTs, Payload instances hold no local state. That means you can run multiple containers behind a load balancer and add more as traffic grows. A practical setup:
- Build Next.js with
output: 'standalone'for small Docker images. - Run two or more containers behind Nginx, a cloud load balancer or your platform's router.
- Use a managed PostgreSQL or MongoDB service with automated backups.
- Keep secrets in environment variables, never in the image.
8. Offload Heavy Work to Background Jobs
Sending emails, syncing with third-party APIs, generating reports and bulk imports should not run inside a request. Payload's built-in Jobs Queue lets you define tasks and run them outside the request cycle, so editor saves stay fast even when hooks trigger expensive work.
9. Monitor Before You Need To
- Track response times and error rates for
/apiroutes separately from public pages. - Enable slow query logging on your database.
- Watch Core Web Vitals in Search Console. Speed problems usually appear there first.
- Load-test the uncached paths (search, filters, authenticated pages), because those are the ones that hit Payload directly.
Production Scalability Checklist
| Area | Setting |
|---|---|
| Rendering | Static generation / ISR with hook-based revalidation |
| Caching | CDN for pages, assets and media; bypass for /admin and /api |
| Database | Indexes on queried fields, connection pooling, managed service |
| Queries | depth: 0 by default, select, explicit limits |
| Media | S3-compatible storage adapter + CDN, pre-generated image sizes |
| Compute | Stateless containers, horizontal scaling behind a load balancer |
| Background work | Jobs Queue instead of in-request processing |
Running multiple sites from one instance? The same principles apply to multi-tenant Payload CMS deployments, with tenant-aware cache tags added on top.
Get a Production Review
Execudea designs, deploys and tunes Payload CMS applications on DigitalOcean, AWS and Docker-based infrastructure. If your Payload site is slowing down under traffic, or you are planning a high-traffic launch, talk to our Payload CMS development team about a performance and architecture review.