LAMP Stack Tuning for Mobile App Backends (2026 Guide)

Tune LAMP for mobile by measuring first, then fixing the usual three: PHP-FPM process limits, MySQL query behavior, and caching. Mobile clients punish sequential DB calls, dropped connections, and retries, so cap worker timeouts, add Redis, and make endpoints idempotent. Defaults built for 2014 WordPress sites won't survive 5G latency.
Part of the complete app development guide.
On this page
- Why mobile clients punish a lazy stack
- Start by measuring, not guessing
- PHP-FPM: the setting that fixes most of it
- Use OPcache properly
- MySQL: indexes and connection discipline
- Cache the stuff that doesn’t change per request
- HTTP-level wins that cost nothing
- When to stop tuning and rebuild
- FAQ
- Do I need to switch to Node or Go to make my mobile API fast?
- How much RAM should a LAMP server have for a mobile backend?
- Is persistent MySQL connections ever the right call?
Most mobile app backends I get called about aren’t slow because of bad code. They’re slow because the LAMP stack underneath them was set up with defaults meant for a 2014 WordPress site. If your app users see spinners on a cellular connection, the bottleneck is almost always PHP-FPM process limits, MySQL query behavior, or a missing cache layer.
Here’s the order I actually work in when tuning a LAMP stack for a mobile backend, plus the config and code that matters.
Why mobile clients punish a lazy stack
Desktop users tolerate a 400ms response. Mobile users don’t, for a few reasons:
- Round-trip latency on LTE/5G is 30-80ms per request. If your API makes four sequential DB calls, that’s 300ms of pure network wait before any work happens.
- Phones drop connections constantly (elevators, tunnels, switching towers). A backend that holds a PHP worker open for 10 seconds during a flaky request runs out of workers fast.
- Battery and data budgets mean clients batch requests, so single requests are often heavier than a desktop page load.
- Mobile apps retry aggressively. Without idempotency and caching, one bad connection turns into three identical DB queries.
None of that is exotic. It just means the default LAMP config, tuned for one request per user per page view, is the wrong shape.
Start by measuring, not guessing
Before touching a config file, get real numbers. Two tools do most of the work:
# Where is time actually going? Enable slow query log.
# /etc/mysql/mysql.conf.d/mysqld.cnf
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.1
log_queries_not_using_indexes = 1
Then hit your own API the way a phone would:
# 50 concurrent users, 30 seconds, against a real endpoint
hey -z 30s -c 50 -H "Authorization: Bearer $TOKEN"
https://api.example.com/v1/feed
If p95 latency is fine under hey but bad in the app, your problem is client-side or network, not the stack. Don’t tune what isn’t broken.
PHP-FPM: the setting that fixes most of it
The single most common misconfiguration is pm.max_children. If it’s too low, requests queue. If it’s too high, MySQL runs out of connections and everything falls over together.
; /etc/php/8.4/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 40
pm.start_servers = 8
pm.min_spare_servers = 8
pm.max_spare_servers = 16
pm.max_requests = 500
; Fail fast instead of hanging the client
request_terminate_timeout = 15s
Size max_children from memory, not vibes. If each PHP worker peaks around 60MB and you have 4GB free for PHP, that’s roughly 60 workers max, and you should stay under it. Leave headroom for MySQL and the OS.
pm.max_requests = 500 recycles workers to contain memory leaks in long-running libraries. It’s cheap insurance.
Use OPcache properly
; /etc/php/8.4/fpm/conf.d/10-opcache.ini
opcache.enable = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.jit = tracing
opcache.jit_buffer_size = 128M
validate_timestamps = 0 means PHP stops stat-ing files on every request, which is a real win. The tradeoff: you must reload PHP-FPM on deploy. Do it in your deploy script or you’ll ship stale code and lose an hour debugging it.
MySQL: indexes and connection discipline
Run EXPLAIN on every query your app runs on a hot path. A missing index on a 500k row table turns a 2ms lookup into a 400ms table scan, and mobile users feel every millisecond of it.
EXPLAIN ANALYZE
SELECT id, body, created_at
FROM posts
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;
-- Composite index that matches the WHERE + ORDER BY
CREATE INDEX idx_posts_user_created
ON posts (user_id, created_at DESC);
Column order matters. (user_id, created_at) lets MySQL filter and sort in one pass. A separate index on each column does not.
On the connection side, persistent connections are the usual footgun. With 40 PHP workers and persistent PDO connections, you can pin 40 MySQL connections that never free up. For most mobile backends, plain connections plus a tuned pool beat persistence:
$pdo = new PDO(
'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4',
$user,
$pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
ATTR_EMULATE_PREPARES => false uses real server-side prepared statements, which is faster and safer. It also makes integer types come back as integers instead of strings, which trips people up if they weren’t expecting it.
Cache the stuff that doesn’t change per request
Mobile APIs have a lot of read-heavy, rarely-changing data: config, feature flags, user profiles, feeds. Redis in front of MySQL handles most of it.
$key = "feed:user:$userId:page:$page";
$cached = $redis->get($key);
if ($cached !== false) {
return json_decode($cached, true);
}
$rows = $pdo->prepare(
'SELECT id, body, created_at FROM posts
WHERE user_id = ? ORDER BY created_at DESC LIMIT 20 OFFSET ?'
);
$rows->execute([$userId, $page * 20]);
$data = $rows->fetchAll();
$redis->setex($key, 60, json_encode($data));
return $data;
A 60-second TTL on a feed is invisible to users and cuts DB load dramatically. Invalidate on write if staleness actually matters; for most feeds, it doesn’t.
HTTP-level wins that cost nothing
- Compression. JSON compresses 70-85%. Enable
gziporbrotliat nginx and stop shipping 200KB of JSON over cellular. - ETags and conditional requests. Return
304 Not Modifiedwhen nothing changed. The phone saves data, you save CPU. - Keep-alive. Mobile clients reuse connections. Set
keepalive_timeout 65;in nginx and let them. - HTTP/2 or HTTP/3. Both reduce connection overhead and handle multiplexed requests better than HTTP/1.1. If you’re on nginx 1.25+, HTTP/3 is a config flag.
When to stop tuning and rebuild
Tuning has a ceiling. If you’re past a few thousand requests per second, or your PHP workers spend most of their time waiting on external APIs, the answer isn’t more max_children. It’s moving the hot paths to something that doesn’t boot a process per request. That’s a bigger conversation, and it’s usually worth having before you throw hardware at the problem.
For most apps in the range where this actually matters (a few hundred to a few thousand concurrent users), a properly tuned LAMP stack on a single well-sized box will outperform a badly configured multi-server setup every time. I’ve done this work for clients across Long Island and NYC, and the pattern holds: measure first, fix PHP-FPM and indexes, add caching, then decide if you need more architecture. If you’re weighing a rebuild against a tune-up, custom web development work starts with exactly this kind of audit.
FAQ
Do I need to switch to Node or Go to make my mobile API fast?
No. A tuned PHP 8.4 stack handles thousands of requests per second. Switch runtimes when you have a measured reason (usually CPU-bound work or heavy concurrency), not because LAMP feels old.
How much RAM should a LAMP server have for a mobile backend?
It depends on your worker count. Budget roughly 60MB per PHP worker plus 1-2GB for MySQL and the OS. A 4GB box comfortably serves a few hundred concurrent users with the config above.
Is persistent MySQL connections ever the right call?
Rarely with PHP-FPM. The worker count and connection count get tangled, and you end up with exhausted MySQL connections under load. Use non-persistent connections and size max_children to match your MySQL max_connections.


