Debugging 502 Bad Gateway: Fixing Slow Laravel 8 Admin DB Queries
Stopping Dashboard Crashes: A Database and Asset Optimization Post-Mortem
It was Monday morning. Our telemetry dashboard, which monitors real-time server activity and customer payment flows for an enterprise SaaS client, was dying.
The symptoms were clear. Dashboard requests were taking 15 to 30 seconds to load. Users were greeting us with 502 Bad Gateway timeouts. Our PHP-FPM worker pools were running out of available processes, and the database server CPU was locked at 100%.
Our client was frantic. They were losing visibility over their operations, and customers couldn’t check their payment history.
As a systems developer, I’ve seen this happen a hundred times. A team builds a beautiful admin dashboard on their local machine with 100 test records. It runs in milliseconds. But the moment that same system faces a production database with two million rows and 500 active users, the unoptimized database queries, bloated client-side code, and unconfigured background processes collide to crash the server.
In this guide, I will take you step-by-step through our technical audit. We will locate the slow SQL queries, optimize our database indexing strategies, set up a bulletproof background queue daemon, eliminate frontend browser memory leaks, and automate server deployments.
Part 1: Finding the Slow Queries with System Logs
When a server is choking under load, do not guess what the problem is. Look at your logs.
First, we isolated the database. We configured MySQL to record any query that took longer than one second to run. You can do this live on your database by logging in as root and running these commands:
-- Enable the slow query log
SET GLOBAL slow_query_log = 'ON';
-- Log any query that takes longer than 1.0 seconds
SET GLOBAL long_query_time = 1.0;
-- Define where the log file is saved
SET GLOBAL slow_query_log_file = '/var/log/mysql/mysql-slow.log';
We let the server run for twenty minutes during a peak traffic hour. When we opened /var/log/mysql/mysql-slow.log, we found a huge file filled with slow-running statements.
To make sense of the mess, we ran a tool called mysqldumpslow to group similar queries together. The tool showed that one specific query was running thousands of times and taking an average of 4.5 seconds per run:
SELECT DATE(created_at) as day, COUNT(id) as total_jobs, SUM(amount) as total_revenue
FROM transactions
WHERE status = 'completed'
GROUP BY DATE(created_at)
ORDER BY day DESC
LIMIT 30;
This query was responsible for drawing the "Last 30 Days Revenue" chart on the main dashboard. Every single time an administrator refreshed their page, this heavy analytical query scanned the entire database.
To see exactly why this query was destroying our CPU, we ran an execution plan by adding EXPLAIN to the beginning of the SQL statement:
EXPLAIN SELECT DATE(created_at) as day, COUNT(id) as total_jobs, SUM(amount) as total_revenue
FROM transactions
WHERE status = 'completed'
GROUP BY DATE(created_at)
ORDER BY day DESC
LIMIT 30;
Analyzing execution plans helps us see how the database engine intends to retrieve our data. For a complete guide on reading execution plans, check the official MySQL EXPLAIN documentation.
The execution plan returned these results:
type: ALL (This means a full table scan. The database had to read every single row from the disk.)
rows: 1,452,098 (It scanned nearly one and a half million rows.)
* Extra: Using temporary; Using filesort (The database was forced to create a temporary table on the hard drive to group and sort the results.)
Part 2: SQL Refactoring and Creating Covering Indexes
A full table scan on 1.4 million rows will choke any CPU. We had two major issues to fix in this query.
Issue 1: The DATE() function prevents index usage
When you apply a function like DATE(created_at) in your SQL queries, the database cannot use a standard index on the created_at column. It has to calculate the DATE() result for every single row first, then perform the query.
To fix this, we modified our Laravel code to perform a raw date range comparison instead of wrapping the database column in a function.
Issue 2: Missing composite indexes
We had separate indexes on status and created_at. But MySQL can typically only use one index per table query. The database engine had to pick either the status index or the created_at index, leaving the other column unindexed during the operation.
We needed to create a composite "covering index" that combines all the columns our query uses: status, created_at, and amount.
Let's write a Laravel database migration file to apply these optimizations safely:
dropIndex(['status']);
$table->dropIndex(['created_at']);
// Create our high-performance composite covering index
// We order columns from most selective (filtering) to least selective
$table->index(['status', 'created_at', 'amount'], 'idx_dashboard_stats_covering');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('transactions', function (Blueprint $table) {
// Rollback to original state
$table->dropIndex('idx_dashboard_stats_covering');
$table->index('status');
$table->index('created_at');
});
}
}
Why does a composite covering index speed up the query?
A composite B-Tree index is like a pre-sorted phone book. The database engine can jump straight to the rows where status is 'completed'.
Because created_at is the next column in our index, those filtered rows are already sorted by date.
And because the amount column is also included in the index, MySQL does not even need to read the main data table from the hard drive. It can perform the SUM(amount) mathematical calculations directly from the index tree stored in RAM. This is called an index-only scan, and it is incredibly fast.
When we ran our EXPLAIN command on the modified query, the results looked vastly different:
type: range
rows: 12,450 (It only scanned the completed transactions for our date range, instead of 1.4 million rows.)
* Extra: Using index (The query was executed entirely within memory.)
The average query response time dropped from 4.5 seconds to 14 milliseconds.
Part 3: Laravel 8 Queue Worker and Daemon Configuration
With the main query database issues resolved, the dashboard load times improved, but we noticed the server was still experiencing intermittent CPU spikes.
We looked at our active processes using the terminal utility htop. We discovered that our background queue jobs were running on a basic system cron job. Every minute, the system was running this command:
* * * cd /var/www/html && php artisan queue:work --once
Why is running queue:work once via cron a bad practice?
Every single minute, the server had to spin up a new PHP process, load the entire Laravel framework kernel, connect to the database, run a single background job, and then terminate. This process of loading and unloading the PHP environment is called "bootstrapping overhead," and it consumes a massive amount of CPU cycles.
If twenty background jobs arrived at the same time, they had to wait for the next minute to start, causing long processing delays.
We needed our queue workers to run as persistent background daemons. A daemon starts once, loads the Laravel framework into the server's RAM, and stays running forever, waiting for jobs to arrive in real-time.
However, running long-lived PHP processes introduces a risk: memory leaks. If a third-party package has a tiny bug, memory will accumulate inside the process until the server runs out of RAM.
To prevent this, we configure our daemon to monitor its own memory consumption using the --memory flag. This tells the worker: "If you exceed 128MB of RAM, shut down cleanly. The server process manager will instantly restart you with a fresh, clean slate."
Here is how we set up a robust system daemon using Systemd.
Step 1: Create the Systemd service file
Create a file at /etc/systemd/system/laravel-worker.service:
[Unit]
Description=Laravel Background Queue Worker
After=network.target mysql.service
[Service]
# Run the worker as our standard web server user
User=www-data
Group=www-data
WorkingDirectory=/var/www/html
# Run the persistent daemon with strict limits
ExecStart=/usr/bin/php /var/www/html/artisan queue:work --queue=default,emails,telemetry --sleep=3 --tries=3 --timeout=90 --memory=128
# Automatically restart the worker if it exits or crashes
Restart=always
RestartSec=3
# Prevent systemd from killing children processes during restarts
KillMode=process
[Install]
WantedBy=multi-user.target
Step 2: Enable and start the service
Run these commands in your server terminal to register our new background worker:
# Reload the systemd configurations
sudo systemctl daemon-reload
# Enable the worker to start automatically when the server boots
sudo systemctl enable laravel-worker.service
# Start the worker right now
sudo systemctl start laravel-worker.service
Step 3: Monitor your background worker
You can check the real-time activity and ensure your background process is running smoothly using this command:
sudo systemctl status laravel-worker.service
By switching from a cron-based worker to a Systemd daemon, we reduced background task processing delays from minutes to milliseconds, and saved a huge amount of CPU cycles.
Part 4: Rebuilding the Front-End to Stop Memory Leaks
Even though our database and queue processing were now optimized, our client complained that after keeping the dashboard open on their office computer for more than an hour, their web browser became sluggish and eventually crashed.
We opened the Chrome DevTools Performance monitor and observed the memory timeline. The heap memory footprint was climbing constantly, starting at 90MB and climbing to over 1.2GB after an hour.
The old site was built using a highly complex Single Page Application (SPA) architecture. Every time the dashboard received a real-time notification via WebSockets, the framework re-rendered the charts and tables.
However, the previous developers had forgotten to clean up the event listeners and charts from the browser memory. Every dynamic update left dead references behind, preventing the browser's garbage collector from freeing the memory.
We decided to scrap the complex framework architecture. We rebuilt the administration interface using a clean, statically compiled dashboard template. I selected Arcone - Bootstrap 5 & Laravel 8 Admin Dashboard Template + HTML Version.
Why did this change solve our memory leaks?
- Plain Bootstrap 5 and jQuery/Vanilla JS components: Because the template uses clean, native DOM manipulations instead of virtual DOM abstractions, we can track exactly when elements are created and destroyed.
- Modular layouts: Arcone is organized logically into isolated files. We don't have global reactive states holding onto references to deleted cards or chart containers.
- Optimized external plugins: The template comes with pre-configured charting libraries that handle real-time data streaming without leaving memory leaks in the browser heap.
To make sure your frontend templates are safe and lightweight, always acquire your files from trusted suppliers. Many developers download templates from untrusted sources, which can contain hidden malware, cryptocurrency miners, or outdated libraries that introduce severe performance degradation.
If you are looking for secure layouts to jumpstart your projects, I highly recommend going to a reputable platform for your HTML Template download assets. We downloaded our base files from GPLPAL, which gave us access to verified, untouched source code, ensuring we weren't introducing any tracking scripts or performance-degrading bloat.
Let's look at how we wrote our clean, memory-safe real-time chart code inside our new Bootstrap dashboard layout.
// real-time-stats.js
(function() {
let telemetryChart = null;
const chartContainer = document.getElementById('telemetry-chart-canvas');
// Store data in a simple fixed-size array to prevent infinite memory growth
const MAX_DATA_POINTS = 20;
let chartDataPoints = [];
let chartLabelPoints = [];
function initChart() {
if (!chartContainer) return;
const ctx = chartContainer.getContext('2d');
// Initialize Chart.js safely
telemetryChart = new Chart(ctx, {
type: 'line',
data: {
labels: chartLabelPoints,
datasets: [{
label: 'Active IoT Connections',
data: chartDataPoints,
borderColor: '#4e73df',
backgroundColor: 'rgba(78, 115, 223, 0.05)',
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0 // Disable heavy rendering animations to save client CPU
},
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// Process new incoming data points from our WebSocket stream
function handleIncomingData(timeString, value) {
if (!telemetryChart) return;
// Add new points
chartDataPoints.push(value);
chartLabelPoints.push(timeString);
// Remove oldest points if we exceed our limit
if (chartDataPoints.length > MAX_DATA_POINTS) {
chartDataPoints.shift();
chartLabelPoints.shift();
}
// Update the chart visual layer efficiently
telemetryChart.update('none'); // Update without running costly animations
}
// Clean up memory when user navigates away or dashboard is closed
function destroyTelemetryChart() {
if (telemetryChart) {
telemetryChart.destroy();
telemetryChart = null;
console.log('Telemetry chart memory cleared.');
}
chartDataPoints = [];
chartLabelPoints = [];
}
// Start everything up
document.addEventListener('DOMContentLoaded', initChart);
// Watch for custom window unload events to clean memory
window.addEventListener('beforeunload', destroyTelemetryChart);
// Expose our dynamic updater globally so our WebSocket listener can trigger it
window.updateDashboardTelemetry = handleIncomingData;
})();
Why is this memory-safe?
- Fixed array sizes (
MAX_DATA_POINTS): The data arrays never grow larger than 20 items. This prevents the browser from holding onto thousands of old data points over time. - Animation suppression (
duration: 0): By disabling the chart draw animations, we prevent the browser from running complex layout calculations every time a new data point arrives. This saves battery life on laptops and mobile devices. - Explicit destruction (
destroyTelemetryChart): If the user navigates away, we cleanly callchart.destroy(). This deletes all references to DOM nodes and canvas elements, allowing the browser's garbage collector to immediately reclaim 100% of the allocated memory.
Part 5: Automating the Server Deployment Pipeline
When you run a high-traffic system, deploying changes manually over SSH is dangerous. You might forget to clear the cache, miss a database migration, or leave your queue workers running old code.
To make deployments reliable and fast, we wrote a custom shell script that automates the entire process. This script runs on our build server during every deployment. It puts our application into maintenance mode, clears caches, runs migrations, and triggers our systemd queue workers to restart safely.
Save this script as deploy.sh in your project's root folder:
#!/bin/bash
Set strict error handling. If any command fails, stop the script immediately.
set -e
echo "=== Starting deployment pipeline ==="
Define paths
APP_DIR="/var/www/html"
WEB_USER="www-data"
Navigate to the application root directory
cd $APP_DIR
1. Activate Laravel maintenance mode
echo "Enabling maintenance mode..."
php artisan down --retry=60 || echo "Site is already in maintenance mode."
2. Pull the latest code from git repository
echo "Pulling latest changes from main branch..."
git pull origin main
3. Install composer dependencies (optimized for production)
echo "Installing composer dependencies..."
composer install --no-dev --optimize-autoloader --no-interaction
4. Clear and rebuild configuration and route caches
This compiles our PHP bootstrap routes and configuration files into static arrays
echo "Optimizing configuration caches..."
php artisan config:cache
php artisan route:cache
php artisan view:cache
5. Run database migrations safely
echo "Running database migrations..."
php artisan migrate --force
6. Optimize asset files
if [ -f "artisan" ]; then
echo "Running static asset cleanup..."
php artisan cache:clear
fi
7. Restart the background systemd queue workers
This forces the daemons to reload and run our updated PHP code files
echo "Restarting background queue daemon..."
sudo systemctl restart laravel-worker.service
8. Deactivate maintenance mode
echo "Disabling maintenance mode..."
php artisan up
echo "=== Deployment complete! Site is back online ==="
How to use this script in production:
- Upload this file to your server as
deploy.sh. - Give it execution permissions using your terminal:
bash chmod +x deploy.sh - Run it whenever you deploy updates:
bash ./deploy.sh
Part 6: Post-Refactoring Performance Metrics
To prove our optimizations worked, we ran a series of load tests and system performance checks before and after applying our database, daemon, and memory improvements.
Here are the technical results:
| Audit Parameter | Before Audit (Unoptimized) | After Audit (Optimized) | System Impact |
|---|---|---|---|
| Main Dashboard Load Time | 15.2 seconds | 0.28 seconds | 98.1% improvement |
| Telemetry DB Query Execution | 4,500ms | 14ms | 99.6% faster queries |
| Server CPU Load (During peaks) | 100% (High risk of crash) | 8% to 11% (Stable) | 89% CPU resource savings |
| Admin Browser Memory Leak | Clomb to 1.2GB (Browser crashed) | Stable at 95MB (Never spikes) | Zero browser crashes |
| Background Task Processing | Delayed up to 60 seconds | Processed in 0.1 seconds | Real-time execution |
| HTTP Response Code Integrity | Regular 502/504 Timeouts | 100% 200 OK (Zero timeouts) | Perfect server reliability |
Strategic Takeaways
Optimizing high-traffic systems doesn't require purchasing expensive server upgrades. It requires understanding database structures, setting up background daemons properly, and keeping your front-end light.
If you are facing performance challenges on your server, follow these rules:
Don't search blindly: Turn on your slow query log and run EXPLAIN to understand exactly how your database engine is fetching your data.
Stop table scans: Create composite covering indexes that combine your filters, sorting, and aggregate columns into a single index tree in RAM.
Ditch cron-based queue processors: Use persistent systemd daemons with strict memory caps to avoid bootstrap overhead and process background tasks in real-time.
Avoid bloated SPA code: Use clean, structured HTML and CSS frameworks. Avoid holding dynamic references in memory without cleaning up.
* Automate everything: Create deployment scripts to handle cache optimization, database changes, and process restarts automatically to eliminate human error.
By spending a few hours digging into your system logs and restructuring your code, you can build incredibly robust websites that run lightning-fast on minimal server resources.
评论 0