Serverless platforms are amazing—until your database goes to sleep.
If you’re running Laravel with a serverless MySQL provider (Railway, PlanetScale, Neon, etc.), you may have seen this dreaded error after a period of inactivity:
SQLSTATE[HY000] [2002] Connection refused
It usually happens when:
- Your Laravel service is still awake
- Your MySQL service has gone idle and slept
- The first incoming request hits Laravel before MySQL wakes up
This article explains:
- Why Laravel cannot reliably handle this problem internally
- Why retries and middleware are not enough
- A practical, serverless-friendly solution using a small Node.js “bridge” service
- Full working code
This approach avoids always-on services, avoids cron abuse, and works with serverless behavior instead of fighting it.
The Problem
Typical setup
- Laravel deployed as a serverless service
- MySQL deployed as a serverless service
- Sessions, auth, or queries depend on MySQL
The failure scenario
- No traffic for some time
- MySQL goes to sleep
- Laravel stays awake or wakes faster
- First request arrives
- Laravel touches the database immediately (sessions, auth, middleware)
- 💥 Connection refused
Why Laravel retry middleware doesn’t solve it
Even with retry middleware like this:
try {
return $next($request);
} catch (Throwable $e) {
// retry
}
Laravel still fails because:
- Session middleware accesses the database before your code runs
- PDO throws fatal connection errors before retries
- Some failures happen during framework bootstrapping
In short:
- Laravel cannot “wait” for MySQL once the request lifecycle starts.
Why Not JavaScript in the Browser?
You might think:
“I’ll just ping the DB using JavaScript when the page loads.”
This fails because:
Laravel may be used as an API
- API consumers don’t run browser JS
- Errors happen before HTML is even returned
- We need a server-side solution.
The Key Insight
Instead of making Laravel wait for MySQL, make something else keep MySQL awake while Laravel is active.
This leads to a companion service pattern.
The Solution: A Small Serverless Node.js “MySQL Waker”
The idea
- A tiny Node.js service
- Triggered by Laravel on incoming requests
- Periodically touches MySQL for a short time window
- Resets its timer on new traffic
- Goes idle naturally afterward
This service:
- Keeps MySQL awake during active usage
- Sleeps when Laravel sleeps
- Never blocks Laravel requests
Architecture Overview
Client → Laravel
↓
MySQL Waker (Node.js)
↓
MySQL
Flow:
- User hits Laravel
- Laravel calls the Node.js waker (non-blocking)
- Node.js schedules periodic MySQL pings
- Each new hit resets the schedule
- After inactivity, everything sleeps
Node.js MySQL Waker — Full Code
Environment variables
PORT=3000
DATABASE_URL=mysql://user:pass@host/db
INTERNAL_KEY=super-secret-key
WAKE_GAP_MINUTES=3
MAX_ATTEMPTS=3
index.js
import express from "express";
import mysql from "mysql2/promise";
const app = express();
app.use(express.json());
/**
* =====================
* CONFIG
* =====================
*/
const PORT = process.env.PORT || 3000;
const DATABASE_URL = process.env.DATABASE_URL;
const INTERNAL_KEY = process.env.INTERNAL_KEY;
const WAKE_GAP_MINUTES = Number(process.env.WAKE_GAP_MINUTES) || 5;
const MAX_ATTEMPTS = Number(process.env.MAX_ATTEMPTS) || 5;
const TICK_INTERVAL_MS = 60_000; // scheduler tick every 1 minute
/**
* =====================
* IN-MEMORY STATE
* =====================
* This flag is CRITICAL.
* When false → NO MySQL connections are made.
*/
let schedulerActive = false;
let schedulerStarted = false;
/**
* =====================
* MYSQL HELPERS
* =====================
*/
async function getConnection() {
return mysql.createConnection(DATABASE_URL);
}
async function ensureTable(conn) {
await conn.execute(`
CREATE TABLE IF NOT EXISTS mysql_wake_jobs (
id TINYINT PRIMARY KEY,
remaining_attempts INT NOT NULL,
next_run_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)
`);
}
async function upsertJob(conn) {
const nextRun = new Date();
await conn.execute(
`
INSERT INTO mysql_wake_jobs (id, remaining_attempts, next_run_at, updated_at)
VALUES (1, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
remaining_attempts = VALUES(remaining_attempts),
next_run_at = VALUES(next_run_at),
updated_at = NOW()
`,
[MAX_ATTEMPTS, nextRun]
);
}
async function wakeMySQL() {
const conn = await getConnection();
try {
await conn.execute("SELECT 1");
} finally {
await conn.end();
}
}
/**
* =====================
* SCHEDULER
* =====================
*/
async function schedulerTick() {
// 🚫 HARD STOP — no DB access if inactive
if (!schedulerActive) return;
let conn;
try {
conn = await getConnection();
const [rows] = await conn.execute(`
SELECT remaining_attempts, next_run_at
FROM mysql_wake_jobs
WHERE id = 1
`);
if (!rows.length) {
schedulerActive = false;
return;
}
const job = rows[0];
const now = new Date();
if (job.remaining_attempts <= 0) {
schedulerActive = false;
return;
}
if (new Date(job.next_run_at) > now) {
return;
}
// 🔥 WAKE MYSQL (ONLY HERE)
await wakeMySQL();
const nextRun = new Date(now.getTime() + WAKE_GAP_MINUTES * 60 * 1000);
await conn.execute(
`
UPDATE mysql_wake_jobs
SET remaining_attempts = remaining_attempts - 1,
next_run_at = ?,
updated_at = NOW()
WHERE id = 1
`,
[nextRun]
);
console.log(
`[wake] MySQL touched | remaining_attempts=${job.remaining_attempts - 1}`
);
} catch (err) {
console.error("[wake] scheduler error:", err.message);
} finally {
if (conn) await conn.end();
}
}
function startScheduler() {
if (schedulerStarted) return;
schedulerStarted = true;
setInterval(schedulerTick, TICK_INTERVAL_MS);
}
/**
* =====================
* HTTP ENDPOINT
* =====================
*/
app.post("/wake-mysql", async (req, res) => {
// 🔐 SECURITY
if (req.headers["x-internal-key"] !== INTERNAL_KEY) {
return res.status(403).end();
}
try {
const conn = await getConnection();
await ensureTable(conn);
await upsertJob(conn);
await conn.end();
// 🧠 Activate scheduler
schedulerActive = true;
startScheduler();
res.json({ status: "ok" });
} catch (err) {
console.error("[wake] endpoint error:", err.message);
res.status(500).json({ error: "mysql_wake_failed" });
}
});
/**
* =====================
* START SERVER
* =====================
*/
app.listen(PORT, () => {
console.log(`[mysql-waker] listening on port ${PORT}`);
});
Laravel Integration
.env
MYSQL_WAKE_ENABLED=true
MYSQL_WAKE_URL=http://mysql-waker.railway.internal/wake-mysql
MYSQL_WAKE_KEY=super-secret-key
Where to call it (safe place)
Early middleware or controller entry point:
use Illuminate\Support\Str;
$requestId = $_SERVER['HTTP_X_REQUEST_ID'] ?? (string) Str::uuid();
try {
if (getenv('MYSQL_WAKE_ENABLED') === 'true') {
$ch = curl_init(getenv('MYSQL_WAKE_URL'));
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3,
CURLOPT_HTTPHEADER => [
'X-Internal-Key: ' . getenv('MYSQL_WAKE_KEY'),
'X-Request-Id: ' . $requestId,
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
error_log(json_encode([
'level' => 'warning',
'service' => 'laravel',
'event' => 'mysql_wake_non_success',
'status' => $status,
'response' => $body,
'request_id' => $requestId,
'ts' => gmdate('c'),
]));
}
}
} catch (\Throwable $e) {
error_log(json_encode([
'level' => 'warning',
'service' => 'laravel',
'event' => 'mysql_wake_exception',
'error' => $e->getMessage(),
'request_id' => $requestId,
'ts' => gmdate('c'),
]));
}
This never blocks requests.
Why This Works
- MySQL is warmed before Laravel touches it
- Laravel never waits on MySQL
- Node.js handles timing (which PHP is bad at)
- Everything sleeps naturally
- No cron jobs
- No always-on infra
Docker Users: Add a Small Startup Delay (Important)
If you are running Laravel inside Docker, there is one more critical detail to avoid cold-start failures.
Even with the MySQL waker in place, Laravel can still boot faster than MySQL on container startup. This usually happens when:
- Both services start at the same time
- Laravel runs migrations or boots the framework immediately
- MySQL is still initializing and not yet accepting connections
To prevent this, add a short delay in your Laravel container entrypoint.
Why this is needed
- Serverless platforms do not guarantee startup order
- MySQL containers often need a few seconds to become ready
- Laravel touches the database during boot (migrations, cache, sessions)
A small delay ensures MySQL is reachable before Laravel begins its lifecycle.
Example entrypoint.sh
#!/bin/sh
set -e
echo "⏳ Waiting before startup..."
sleep 5
echo "📁 Ensuring storage directories..."
mkdir -p storage/framework/cache
mkdir -p storage/framework/sessions
mkdir -p storage/framework/views
mkdir -p storage/logs
mkdir -p bootstrap/cache
chmod -R 775 storage bootstrap/cache
echo "🧹 Clearing caches..."
php artisan config:clear
php artisan route:clear
echo "⚡ Caching config & routes..."
php artisan config:cache
php artisan route:cache
echo "🗄️ Running migrations..."
php artisan migrate --force
echo "🚀 Starting Laravel..."
exec php artisan serve --host=0.0.0.0 --port=${PORT:-8080}
Key point
This delay is not a hack — it’s a practical guard against nondeterministic container startup.
Combined with the Node.js MySQL waker:
- The startup delay handles cold boots
- The waker handles idle sleep wake-ups
- Laravel remains fast and error-free
Together, they cover both failure modes cleanly.
Closing Thoughts
Serverless databases introduce a timing problem that most frameworks aren’t designed to handle.
Instead of forcing Laravel to retry impossible failures, this solution:
- Accepts serverless behavior
- Uses the right tool for the job
- Keeps systems loosely coupled
- Remains cost-efficient
This pattern—a short-lived companion service that smooths cold starts—is broadly useful and rarely documented.
If you’re running Laravel with serverless MySQL and seeing random 2002 errors after idle periods, this approach can save you a lot of frustration.
Happy building 🚀