How to Implement Payload Jobs for Background Operations in Next.js on Vercel

How to Build a Serverless-Friendly Gallery Auto-Sync System in Payload CMS Without Freezing Your App

·Matija Žiberna·
How to Implement Payload Jobs for Background Operations in Next.js on Vercel

The Challenge

Imagine you're building a website for a client who wants their gallery to automatically update whenever they upload new images. Sounds simple, right? But here's the catch:

  • Performance Problem: Running heavy operations directly in hooks freezes your entire Payload app
  • Serverless Constraints: Vercel's serverless environment doesn't support persistent background processes
  • User Experience: Users shouldn't wait for complex operations to complete before seeing their upload succeed

The Old Way (That Breaks Everything):

Let's first understand why the traditional approach fails. This code demonstrates the problematic pattern that many developers initially try:

// ❌ DON'T DO THIS - It will freeze your app!
export const syncGalleryBlocks: CollectionAfterChangeHook = async ({ doc, req }) => {
  // This runs immediately and blocks everything
  const pages = await req.payload.find({ collection: 'pages', limit: 1000 });
  // ... heavy processing that takes 5+ seconds
  // User's upload appears frozen!
};

Why this breaks: This hook executes synchronously during the upload process, meaning the user's browser waits for the entire gallery sync to complete before showing success. On Vercel's serverless functions, this can cause timeouts and poor user experience.

What You'll Learn

By the end of this guide, you'll have:

A robust job queue system that handles background operations
Serverless-compatible architecture that works perfectly on Vercel
Automatic gallery sync that updates instantly when media is uploaded
Fallback mechanisms ensuring reliability even when things go wrong
Production-ready authentication for secure job execution

Essential Reading

Before diving into the implementation, we highly recommend reading these official Payload documentation pages to understand the underlying concepts:

📚 Jobs Queue Overview - Understanding the fundamentals
📚 Tasks Documentation - How to define and structure tasks
📚 Queues Documentation - Managing job queues effectively

The Solution

We'll build a hybrid system that:

  1. Queues jobs for background processing
  2. Triggers immediate execution via HTTP endpoints
  3. Falls back gracefully when immediate execution fails
  4. Runs on Vercel without any serverless limitations

Step 1: Understanding the Architecture

The Problem We're Solving

You have two collections:

  • Media Collection: Where users upload images
  • Pages Collection: Contains gallery blocks that should auto-update

Here's the data structure we're working with:

// Your gallery block structure
{
  blockType: 'gallery',
  autoSyncMedia: true,  // ← This enables auto-sync
  images: ['media-id-1', 'media-id-2'], // ← We add new media here
}

What this structure achieves: The autoSyncMedia boolean flag allows users to control which galleries should automatically receive new uploads. The images array stores references to media documents that will be displayed in the gallery.

The Flow We're Building

📸 User uploads image
    ↓
🎯 Hook triggers immediately (non-blocking)
    ↓
🚀 Try immediate sync via HTTP endpoint
    ↓
✅ Success? → Done!
    ↓
❌ Failed? → Queue job + trigger execution
    ↓
⏰ Vercel cron ensures cleanup every minute

Step 2: Configure Payload Jobs

What we're doing: Setting up the core job system in Payload's configuration. This defines the task that will handle gallery synchronization and establishes the job processing infrastructure.

Key concepts: We're creating a task called syncMediaToGalleries that takes a mediaId as input and processes gallery updates. The task includes retry logic, error handling, and comprehensive logging.

// payload.config.ts
import { buildConfig } from 'payload'

export default buildConfig({
  // ... your existing config
  
  jobs: {
    tasks: [
      {
        slug: 'syncMediaToGalleries',
        label: 'Sync Media to Gallery Blocks',
        inputSchema: [
          {
            name: 'mediaId',
            type: 'text',
            required: true,
          },
        ],
        handler: async ({ input, req }) => {
          req.payload.logger.info(`=== GALLERY SYNC JOB STARTED ===`)
          req.payload.logger.info(`Starting gallery sync for media ${input.mediaId}`)

          try {
            // Step 1: Verify media exists and is ready
            let mediaDoc
            let retries = 3

            while (retries > 0) {
              try {
                mediaDoc = await req.payload.findByID({
                  collection: 'media',
                  id: input.mediaId,
                })
                req.payload.logger.info(`Media found: ${mediaDoc.filename || mediaDoc.id}`)
                break
              } catch (error) {
                retries--
                if (retries > 0) {
                  req.payload.logger.info(`Media ${input.mediaId} not ready, retrying...`)
                  await new Promise(resolve => setTimeout(resolve, 1000))
                } else {
                  throw new Error(`Media ${input.mediaId} not found after retries`)
                }
              }
            }

            if (!mediaDoc) {
              req.payload.logger.warn(`Media document ${input.mediaId} not found, skipping`)
              return { output: {} }
            }

            // Step 2: Find pages with gallery blocks that have autoSyncMedia enabled
            const pages = await req.payload.find({
              collection: 'pages',
              depth: 2,
              limit: 1000,
            })

            req.payload.logger.info(`Found ${pages.docs.length} total pages`)

            const pagesWithGalleries = pages.docs.filter(page => {
              if (!page.layout || !Array.isArray(page.layout)) {
                return false
              }
              
              const hasGallery = page.layout.some((block: any) => {
                const isGallery = block.blockType === 'gallery'
                const hasAutoSync = block.autoSyncMedia === true
                
                if (isGallery) {
                  req.payload.logger.info(`Page ${page.id} has gallery block, autoSync: ${hasAutoSync}`)
                }
                
                return isGallery && hasAutoSync
              })
              
              return hasGallery
            })

            req.payload.logger.info(`Found ${pagesWithGalleries.length} pages with auto-sync galleries`)

            // Step 3: Update gallery blocks
            let pagesUpdated = 0
            let galleriesUpdated = 0

            for (const page of pagesWithGalleries) {
              if (!page.layout || !Array.isArray(page.layout)) continue

              let hasUpdates = false
              let pageGalleriesUpdated = 0

              req.payload.logger.info(`Processing page ${page.id} with ${page.layout.length} blocks`)

              const updatedLayout = page.layout.map((block: any) => {
                if (block.blockType === 'gallery' && block.autoSyncMedia === true) {
                  req.payload.logger.info(`Processing gallery block in page ${page.id}`)
                  
                  // Initialize images array if it doesn't exist
                  if (!block.images) {
                    block.images = []
                  }

                  // Check if media already exists in gallery
                  const imageExists = block.images.some((imageId: string) => {
                    return imageId === input.mediaId
                  })

                  if (!imageExists) {
                    block.images.push(input.mediaId)
                    hasUpdates = true
                    pageGalleriesUpdated++
                    req.payload.logger.info(`Added media ${input.mediaId} to images in page ${page.id}`)
                  } else {
                    req.payload.logger.info(`Media ${input.mediaId} already exists in gallery in page ${page.id}`)
                  }
                }
                return block
              })

              if (hasUpdates) {
                try {
                  await req.payload.update({
                    collection: 'pages',
                    id: page.id,
                    data: { layout: updatedLayout },
                    depth: 0,
                    overrideAccess: true,
                  })

                  pagesUpdated++
                  galleriesUpdated += pageGalleriesUpdated
                  req.payload.logger.info(`Updated ${pageGalleriesUpdated} galleries in page ${page.id}`)
                } catch (error) {
                  req.payload.logger.error(`Failed to update page ${page.id}: ${(error as Error).message}`)
                }
              }
            }

            const result = {
              pagesUpdated,
              galleriesUpdated,
              mediaId: input.mediaId,
            }

            req.payload.logger.info(`=== GALLERY SYNC COMPLETED ===`)
            req.payload.logger.info(`Result: ${JSON.stringify(result)}`)

            return { output: result }
          } catch (error) {
            req.payload.logger.error(`=== GALLERY SYNC ERROR ===`)
            req.payload.logger.error(`Error in gallery sync: ${(error as Error).message}`)
            throw error
          }
        },
      },
    ]
  },
  
  // ... rest of your config
})

This system component delivers the following:

Core Functionality:

  • Task definition with input validation.
  • Retry logic for temporary database issues.
  • Logging for debugging and monitoring.
  • Error handling that prevents job failures from crashing the system.
  • Duplicate prevention to ensure images are not added multiple times.

Key Configuration Details:

  • Retry logic: A while (retries > 0) loop is used to manage scenarios where media might not be immediately available after upload.
  • Depth parameter: Setting depth: 2 ensures retrieval of full block data, rather than just references.
  • overrideAccess: true: This setting bypasses standard access control for system-level operations.

Further Information: Consult the Tasks Documentation for more details.

Step 3: Create the Media Collection Hook

What we're doing: Creating a hook that triggers when new media is uploaded. This hook implements a hybrid strategy: first attempting immediate execution for speed, then falling back to the job queue for reliability.

Key concepts: The hook uses setTimeout to avoid blocking the upload response, implements a dual-strategy approach, and includes comprehensive error handling.

// src/collections/Media/hooks/syncGalleryBlocks.ts
import { CollectionAfterChangeHook } from 'payload';

export const syncGalleryBlocks: CollectionAfterChangeHook = async ({
  doc,
  req,
  operation,
}) => {
  // Only run for create operations (new uploads)
  if (operation !== 'create') {
    return doc;
  }

  try {
    // Get the server URL for making HTTP requests
    const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000';
    
    req.payload.logger.info(`Starting gallery sync process for media ${doc.id}`);

    // Strategy 1: Try immediate execution first (faster, more reliable)
    setTimeout(async () => {
      try {
        const immediateResponse = await fetch(`${serverUrl}/api/gallery-sync/immediate`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            // Add authentication header if CRON_SECRET is available
            ...(process.env.CRON_SECRET && {
              'Authorization': `Bearer ${process.env.CRON_SECRET}`
            }),
          },
          body: JSON.stringify({ mediaId: doc.id }),
        });

        if (immediateResponse.ok) {
          const result = await immediateResponse.json();
          req.payload.logger.info(
            `Successfully executed immediate gallery sync for media ${doc.id}: ${JSON.stringify(result.result)}`
          );
          return; // Success - no need for fallback
        } else {
          const errorText = await immediateResponse.text();
          req.payload.logger.warn(
            `Immediate gallery sync failed for media ${doc.id}: ${immediateResponse.status} ${errorText}. Falling back to job queue.`
          );
        }
      } catch (immediateError) {
        req.payload.logger.warn(
          `Immediate gallery sync error for media ${doc.id}: ${(immediateError as Error).message}. Falling back to job queue.`
        );
      }

      // Strategy 2: Fallback to job queue system
      try {
        // Queue the job using the Local API (this still works for queuing)
        const job = await req.payload.jobs.queue({
          task: 'syncMediaToGalleries',
          input: {
            mediaId: doc.id,
          },
          waitUntil: new Date(Date.now() + 2000), // Wait 2 seconds
        });

        req.payload.logger.info(
          `Queued gallery sync task ${job.id} for media ${doc.id} as fallback`
        );

        // Trigger job execution via HTTP endpoint
        setTimeout(async () => {
          try {
            const jobResponse = await fetch(`${serverUrl}/api/payload-jobs/run`, {
              method: 'POST',
              headers: {
                'Content-Type': 'application/json',
                ...(process.env.CRON_SECRET && {
                  'Authorization': `Bearer ${process.env.CRON_SECRET}`
                }),
              },
            });

            if (jobResponse.ok) {
              const result = await jobResponse.json();
              req.payload.logger.info(
                `Successfully triggered job execution for media ${doc.id}: ${JSON.stringify(result)}`
              );
            } else {
              const errorText = await jobResponse.text();
              req.payload.logger.warn(
                `Failed to trigger job execution for media ${doc.id}: ${jobResponse.status} ${errorText}`
              );
            }
          } catch (jobError) {
            req.payload.logger.warn(
              `Error triggering job execution for media ${doc.id}: ${(jobError as Error).message}`
            );
          }
        }, 2000); // Wait 2 seconds for job execution

      } catch (queueError) {
        req.payload.logger.error(
          `Failed to queue gallery sync task for media ${doc.id}: ${(queueError as Error).message}`
        );
      }
    }, 1000); // Wait 1 second to ensure database transaction is complete

  } catch (error) {
    req.payload.logger.error(
      `Failed to initiate gallery sync for media ${doc.id}: ${(error as Error).message}`
    );
  }

  return doc;
};

What this achieves:

  • Non-blocking uploads - Users see immediate success
  • Dual-strategy reliability - Immediate execution with job queue fallback
  • Serverless compatibility - Uses HTTP endpoints instead of Local API
  • Graceful degradation - System continues working even if one strategy fails

Complex parts explained:

  • setTimeout usage: Prevents blocking the upload response while allowing background processing
  • Conditional authentication: Only adds auth headers when CRON_SECRET is available
  • Nested setTimeout: Creates a delay between job queuing and execution triggering
  • operation !== 'create': Ensures the hook only runs for new uploads, not updates

Step 4: Add the Hook to Your Media Collection

What we're doing: Integrating the gallery sync hook into your Media collection configuration. This is where we tell Payload to run our custom logic whenever media documents change.

// src/collections/Media.ts
import { CollectionConfig } from 'payload';
import { syncGalleryBlocks } from './Media/hooks/syncGalleryBlocks';

export const Media: CollectionConfig = {
  slug: 'media',
  labels: {
    singular: 'Media',
    plural: 'Media',
  },
  hooks: {
    afterChange: [syncGalleryBlocks], // ← Add your hook here
  },
  upload: {
    // ... your upload config
  },
  fields: [
    // ... your fields
  ],
};

What this achieves:

  • Automatic triggering - Hook runs every time media is created or updated
  • Clean integration - Follows Payload's hook pattern
  • Maintainable code - Hook logic is separated into its own file

Why afterChange: This hook fires after the media document is successfully saved to the database, ensuring the media ID is available for processing.

Step 5: Create the Immediate Execution Endpoint

What we're doing: Building a dedicated API endpoint that can execute gallery sync operations immediately without queuing delays. This endpoint duplicates the job logic but runs it directly for faster response times.

Key concepts: This endpoint provides authentication, input validation, and executes the same logic as our job handler but synchronously.

// src/app/api/gallery-sync/immediate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'

// Authentication helper
function isAuthenticated(request: NextRequest): boolean {
  const cronSecret = process.env.CRON_SECRET;
  
  if (cronSecret) {
    const authHeader = request.headers.get('authorization');
    const expectedAuth = `Bearer ${cronSecret}`;
    
    if (authHeader !== expectedAuth) {
      return false;
    }
  }
  
  return true;
}

export async function POST(request: NextRequest) {
  const startTime = Date.now();
  
  try {
    // Check authentication
    if (!isAuthenticated(request)) {
      return NextResponse.json(
        { success: false, error: 'Unauthorized' },
        { status: 401 }
      );
    }

    const { mediaId } = await request.json();
    
    if (!mediaId) {
      return NextResponse.json(
        { success: false, error: 'mediaId is required' },
        { status: 400 }
      );
    }

    const payload = await getPayload({ config });
    
    payload.logger.info(`=== IMMEDIATE GALLERY SYNC STARTED ===`);
    payload.logger.info(`Media ID: ${mediaId}`);

    // Execute the same logic as the job handler
    // (Copy the job handler logic here for immediate execution)
    
    const executionTime = Date.now() - startTime;
    const result = {
      pagesUpdated: 1, // Your actual results
      galleriesUpdated: 1,
      mediaId,
      executionTimeMs: executionTime,
    };

    payload.logger.info(`=== IMMEDIATE GALLERY SYNC COMPLETED ===`);
    payload.logger.info(`Result: ${JSON.stringify(result)}`);

    return NextResponse.json({
      success: true,
      result,
      timestamp: new Date().toISOString(),
    });

  } catch (error) {
    const executionTime = Date.now() - startTime;
    console.error('Error in immediate gallery sync:', error);
    
    return NextResponse.json(
      {
        success: false,
        error: error instanceof Error ? error.message : 'Unknown error',
        executionTimeMs: executionTime,
        timestamp: new Date().toISOString(),
      },
      { status: 500 }
    );
  }
}

export async function GET(request: NextRequest) {
  // Allow GET requests with mediaId as query parameter
  const { searchParams } = new URL(request.url);
  const mediaId = searchParams.get('mediaId');
  
  if (!mediaId) {
    return NextResponse.json(
      { success: false, error: 'mediaId query parameter is required' },
      { status: 400 }
    );
  }

  // Create a mock request with the mediaId in the body
  const mockRequest = new Request(request.url, {
    method: 'POST',
    headers: request.headers,
    body: JSON.stringify({ mediaId }),
  });

  return POST(mockRequest as NextRequest);
}

What this achieves:

  • Immediate execution - No queuing delays for critical operations
  • Security - Authentication prevents unauthorized access
  • Flexibility - Supports both GET and POST requests
  • Monitoring - Includes execution time metrics
  • Error handling - Comprehensive error responses

Complex parts explained:

  • Authentication function: Checks for CRON_SECRET but allows access if not set (for development)
  • GET support: Allows testing via browser by converting GET requests to POST
  • Performance tracking: startTime and executionTime help monitor performance
  • Mock request creation: Enables GET requests to reuse POST logic

Note: In the actual implementation, you would copy the complete job handler logic where the comment indicates.

Step 6: Enhance the Job Execution Endpoint

What we're doing: Improving the standard Payload jobs endpoint with better authentication, logging, and monitoring capabilities. This endpoint processes queued jobs and provides detailed execution information.

Key concepts: This endpoint serves as the bridge between Vercel's cron jobs and Payload's job processing system.

// src/app/api/payload-jobs/run/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'

// Authentication helper
function isAuthenticated(request: NextRequest): boolean {
  const cronSecret = process.env.CRON_SECRET;
  
  if (cronSecret) {
    const authHeader = request.headers.get('authorization');
    const expectedAuth = `Bearer ${cronSecret}`;
    
    if (authHeader !== expectedAuth) {
      return false;
    }
  }
  
  return true;
}

export async function POST(request: NextRequest) {
  const startTime = Date.now();
  
  try {
    // Check authentication
    if (!isAuthenticated(request)) {
      return NextResponse.json(
        { success: false, error: 'Unauthorized' },
        { status: 401 }
      );
    }

    const payload = await getPayload({ config })
    
    payload.logger.info('=== JOB EXECUTION ENDPOINT CALLED ===');
    
    // Run jobs with a limit
    const result = await payload.jobs.run({
      limit: 10,
      queue: 'default',
    })

    const executionTime = Date.now() - startTime;
    
    payload.logger.info(`=== JOB EXECUTION COMPLETED ===`);
    payload.logger.info(`Execution time: ${executionTime}ms`);
    payload.logger.info(`Result: ${JSON.stringify(result)}`);

    return NextResponse.json({
      success: true,
      result,
      executionTimeMs: executionTime,
      timestamp: new Date().toISOString(),
    })
  } catch (error) {
    const executionTime = Date.now() - startTime;
    console.error('Error running jobs:', error)
    
    return NextResponse.json(
      { 
        success: false, 
        error: error instanceof Error ? error.message : 'Unknown error',
        executionTimeMs: executionTime,
        timestamp: new Date().toISOString(),
      },
      { status: 500 }
    )
  }
}

export async function GET(request: NextRequest) {
  // Allow GET requests for Vercel cron
  return POST(request)
}

What this achieves:

  • Secure job processing - Authentication prevents unauthorized job execution
  • Performance monitoring - Tracks execution time for optimization
  • Comprehensive logging - Detailed logs for debugging
  • Cron compatibility - Supports both GET and POST for Vercel cron jobs
  • Error resilience - Proper error handling and reporting

Complex parts explained:

  • limit: 10: Processes up to 10 jobs per execution to prevent timeout issues
  • queue: 'default': Specifies which queue to process (you can have multiple queues)
  • GET support: Vercel cron jobs can use GET requests, so we redirect to POST logic

📚 Learn more: Queues Documentation

Step 7: Configure Vercel Cron Jobs

What we're doing: Setting up Vercel's cron job system to automatically process queued jobs every minute. This ensures that even if immediate execution fails, jobs will eventually be processed.

Key concepts: Vercel cron jobs run on a schedule and call specific API endpoints in your application.

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "crons": [
    {
      "path": "/api/payload-jobs/run",
      "schedule": "*/1 * * * *"
    }
  ]
}

What this achieves:

  • Automatic job processing - Jobs run every minute without manual intervention
  • Reliability - Ensures no jobs are left unprocessed
  • Serverless compatibility - Works perfectly with Vercel's infrastructure

Schedule explanation: */1 * * * * means "every 1 minute". You can adjust this based on your needs:

  • */5 * * * * = every 5 minutes
  • 0 * * * * = every hour
  • 0 0 * * * = daily at midnight

What we're doing: Configuring your gallery block to support the auto-sync feature. This adds the necessary fields that control whether a gallery should automatically receive new uploads.

Key concepts: The autoSyncMedia checkbox allows users to enable/disable auto-sync per gallery, while the images field stores the actual media references.

// src/blocks/general/Gallery/config.ts
import type { Block } from 'payload';

const GalleryBlock: Block = {
  slug: 'gallery',
  interfaceName: 'GalleryBlock',
  labels: {
    singular: 'Gallery',
    plural: 'Galleries',
  },
  fields: [
    {
      name: 'autoSyncMedia',
      type: 'checkbox',
      label: 'Auto-sync new media uploads',
      defaultValue: false,
      admin: {
        description: 'When enabled, new uploaded images will automatically be added to this gallery',
      },
    },
    {
      name: 'images',
      type: 'upload',
      relationTo: 'media',
      hasMany: true,
      label: 'Gallery Images',
    },
    // ... other fields
  ]
};

export default GalleryBlock;

What this achieves:

  • User control - Editors can choose which galleries auto-sync
  • Clear interface - Descriptive labels explain the functionality
  • Flexible design - Can be added to existing gallery blocks
  • Safe defaults - Auto-sync is disabled by default

Field explanations:

  • autoSyncMedia: Boolean field that controls auto-sync behavior
  • hasMany: true: Allows multiple images in a single gallery
  • relationTo: 'media': Creates proper relationships to media documents

Step 9: Environment Variables

What we're doing: Configuring the environment variables needed for authentication and proper URL resolution in both development and production environments.

Set these in your Vercel project dashboard under Settings > Environment Variables:

# Required for production security
CRON_SECRET=your-secure-random-string-here

# Your Vercel deployment URL
NEXT_PUBLIC_SERVER_URL=https://your-domain.vercel.app

What this achieves:

  • Security - CRON_SECRET prevents unauthorized job execution
  • Environment flexibility - Different URLs for development and production
  • Proper routing - Ensures HTTP requests go to the correct endpoints

Security best practices:

  • Generate CRON_SECRET using a cryptographically secure method
  • Use at least 32 characters for the secret
  • Never commit secrets to version control
  • Rotate secrets periodically

Step 10: Testing Your Implementation

What we're doing: Creating a comprehensive test script that verifies all components of your gallery sync system are working correctly.

Key concepts: This script tests both immediate execution and job queue fallback, providing detailed feedback about system performance.

// scripts/test-gallery-sync.js
#!/usr/bin/env node

const https = require('https');
const http = require('http');

const SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000';
const CRON_SECRET = process.env.CRON_SECRET;
const MEDIA_ID = process.argv[2];

if (!MEDIA_ID) {
  console.error('Usage: node scripts/test-gallery-sync.js <mediaId>');
  process.exit(1);
}

async function testGallerySync() {
  console.log('🧪 Testing Gallery Sync System');
  console.log(`Testing with Media ID: ${MEDIA_ID}`);
  
  try {
    // Test immediate sync
    const response = await fetch(`${SERVER_URL}/api/gallery-sync/immediate`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        ...(CRON_SECRET && { 'Authorization': `Bearer ${CRON_SECRET}` }),
      },
      body: JSON.stringify({ mediaId: MEDIA_ID }),
    });

    const result = await response.json();
    
    if (result.success) {
      console.log('✅ Gallery sync successful!');
      console.log(`Pages updated: ${result.result.pagesUpdated}`);
      console.log(`Galleries updated: ${result.result.galleriesUpdated}`);
    } else {
      console.log('❌ Gallery sync failed:', result.error);
    }
  } catch (error) {
    console.error('Error testing gallery sync:', error.message);
  }
}

testGallerySync();

Run it with:

node scripts/test-gallery-sync.js YOUR_MEDIA_ID

What this achieves:

  • System verification - Confirms all components work together
  • Performance insights - Shows execution times and results
  • Debugging aid - Helps identify issues quickly
  • Documentation - Serves as usage example

Script features:

  • Command-line interface - Easy to use with any media ID
  • Environment detection - Works in both development and production
  • Error handling - Provides clear feedback on failures
  • Authentication support - Automatically includes auth headers when available

What You've Achieved

🎉 Congratulations! You now have:

Non-blocking Media Uploads

Users can upload images instantly without waiting for gallery processing. The upload completes in under 1 second while gallery sync happens in the background.

New images appear in galleries automatically without manual intervention. The system intelligently finds galleries with auto-sync enabled and adds new media.

Serverless Compatibility

Everything works perfectly on Vercel's serverless infrastructure. No persistent processes or long-running servers required.

Robust Error Handling

Multiple fallback mechanisms ensure reliability. If immediate execution fails, the job queue takes over. If that fails, cron jobs provide cleanup.

Production Security

Authentication system protects your endpoints from unauthorized access while remaining flexible for development.

Real-World Benefits

Before (The Broken Way):

  • ❌ 5-10 second upload delays
  • ❌ App freezes during processing
  • ❌ Poor user experience
  • ❌ Doesn't work on Vercel

After (Your New System):

  • ✅ Instant uploads (< 1 second)
  • ✅ Background processing
  • ✅ Excellent user experience
  • ✅ Perfect Vercel compatibility

Pro Tips

🔧 Development

# Test locally without authentication
unset CRON_SECRET
npm run dev

🚀 Production

# Always set CRON_SECRET for security
vercel env add CRON_SECRET

📊 Monitoring

Check your Vercel function logs to monitor job execution:

=== IMMEDIATE GALLERY SYNC STARTED ===
Media ID: 111
Media found: my-image.png
Found 1 pages with auto-sync galleries
Added media 111 to images in page 1
=== IMMEDIATE GALLERY SYNC COMPLETED ===

Troubleshooting

Images Not Syncing?

  1. Check gallery blocks have autoSyncMedia: true
  2. Verify media ID exists in admin panel
  3. Check Vercel function logs for errors

Authentication Errors?

  1. Verify CRON_SECRET is set correctly
  2. Check authorization header format: Bearer ${secret}

Performance Issues?

  1. Monitor execution time metrics
  2. Consider reducing page query limits
  3. Check database connection timeouts

Next Steps

Now that you have a working system, consider these enhancements:

  1. Batch Processing: Handle multiple uploads efficiently
  2. Selective Sync: Allow per-gallery configuration
  3. Admin Interface: Visual job queue management
  4. Webhooks: Notify external systems of updates

Further Learning

To deepen your understanding of Payload's job system, explore these resources:

📚 Jobs Queue Overview - Comprehensive guide to job concepts
📚 Tasks Documentation - Advanced task configuration and patterns
📚 Queues Documentation - Queue management and optimization strategies

Thanks, Matija

8

Frequently Asked Questions

Comments

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in