Get $500 to try Pie.host for 90 days. Signup

CPU Is at 20%, but Your App Is Slow. What Should You Do?

Is your application slow despite low CPU usage? Learn how to diagnose Linux memory pressure, disk I/O, slow database queries, connection pools, and network latency.

You open your server dashboard because users are complaining that your application is slow. You check CPU usage, expecting to find a spike.

But CPU usage is only 20%.

The server doesn't look overloaded. There is plenty of CPU capacity left, yet pages take several seconds to load, API requests time out, and users keep refreshing.

So what's going on?

The first thing to understand is that low CPU usage doesn't necessarily mean your application has enough resources or that everything is working correctly. Your application might be waiting for something else: a database query, a disk operation, a network response, or a free connection from a connection pool.

Instead of adding more CPU or moving to a bigger server, start by finding out where the time is going.

Let's walk through the checks I would perform on a Linux server.

  1. Check memory pressure first

Start with:

free -h

Pay attention to the available column rather than looking only at the used memory figure.

Linux deliberately uses spare RAM for filesystem caching, so high memory usage by itself isn't necessarily a problem. What matters is whether the system has enough memory available for applications to operate comfortably.

If memory pressure gets severe, Linux may move memory pages to swap. Accessing swapped-out memory can be much slower than accessing RAM, especially when the system is constantly moving data between the two.

To check whether swapping is happening, run:

vmstat 1

Look at these columns:

  • si: memory swapped in per second.
  • so: memory swapped out per second.
  • wa: percentage of CPU time spent waiting for I/O.

If si and so keep showing activity alongside slow responses and memory pressure, investigate further. Occasional swap activity isn't automatically a problem.

You can also inspect memory usage by process:

ps aux --sort=-%mem | head -10

This helps identify processes consuming the most memory.

If one application process is using far more memory than expected, investigate memory leaks, excessive caching, large in-memory datasets, or unexpectedly high concurrency.

  1. Find out whether the disk is slowing things down

Your CPU can sit at 20% because processes are waiting for storage operations to finish.

This is common when applications write large amounts of data, databases perform random disk reads, or a storage volume becomes saturated.

Install sysstat if the iostat command isn't available, then run:

iostat -xz 1

Focus on a few metrics:

  • await: average time for I/O requests to complete, including queueing.
  • %util: how busy the device is.
  • Read and write throughput: how much data the device is handling.

Don't rely on a single metric. High await combined with growing queues and poor application response times is a stronger indication of a storage bottleneck than %util alone. Some modern SSDs and virtualized storage systems can handle concurrent I/O even when utilization is high.

If disk performance looks suspicious, inspect disk activity and system logs:

sudo dmesg -T | tail -100

Look for storage errors, timeouts, or filesystem warnings.

For database-heavy applications, disk latency can be particularly important because a slow query may need to read data from storage before it can return a result.

  1. Check your database's slow query log

This is one of the first places I would look if the application is slow but CPU usage remains low.

Imagine an API endpoint that needs to fetch a user's account, recent transactions, and subscription details. If one database query takes three seconds, the application may spend most of that time waiting rather than doing CPU-intensive work.

Adding more CPU won't make a poorly indexed query magically fast.

For MySQL, check whether the slow query log is enabled:

SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'slow_query_log_file';

If logging is enabled, inspect the configured log file. On a Linux server, you can follow it with:

sudo tail -f /path/to/mysql-slow.log

Replace the path with the value returned by MySQL.

Look for queries that take a long time, execute frequently, or examine far more rows than necessary.

For a suspicious query, run:

EXPLAIN SELECT ...;

Use the actual query in place of the placeholder. EXPLAIN helps you understand the execution plan, including which indexes MySQL expects to use.

Common causes include:

  • Missing indexes on frequently filtered or joined columns.
  • Queries that return thousands of unnecessary rows.
  • N+1 queries in application code.
  • Expensive joins or sorting.
  • Locks that make queries wait for other transactions.

Don't immediately add indexes to every column. Indexes also consume storage and make writes more expensive. Find the queries that matter, understand their execution plans, and optimize accordingly.

If you're using PostgreSQL, the equivalent investigation starts with pgstatstatements, where available, and tools such as EXPLAIN (ANALYZE, BUFFERS) for carefully selected queries.

  1. Check application logs for slow requests

Your database might be healthy, but the application could still be taking too long to respond.

For example, an API request might:

  1. Authenticate the user.
  2. Query the database.
  3. Call an external payment or email service.
  4. Wait for a response.
  5. Serialize the result and send it to the client.

If the external service takes four seconds, your application can remain slow even when CPU usage is low.

Start by checking the application logs around the time users experienced the problem.

For a systemd-managed service:

sudo journalctl -u your-service --since "15 minutes ago"

Replace your-service with the actual systemd unit name.

Look for timeouts, retries, connection errors, queue delays, and repeated warnings.

However, ordinary error logs won't always tell you which requests are slow. Ideally, your application should record request duration, endpoint, status code, and a request ID so you can trace a slow request across services.

For a Laravel application, you can inspect the Laravel log files and add request timing or database query instrumentation where needed.

The goal is to answer a specific question: Where did those extra seconds go?

  1. Check connection pools and worker limits

Sometimes the application has enough CPU and RAM, but it cannot process more requests because it has run out of available workers or connections.

This is easy to overlook.

Consider a Node.js service with a database connection pool limited to ten connections. If all ten connections are busy executing slow queries, new requests may wait for a connection even though the CPU is mostly idle.

The same kind of problem can occur with:

  • PHP-FPM workers.
  • Database connection pools.
  • HTTP client connection pools.
  • Job queue workers.
  • Reverse proxy connection limits.
  • Application-level semaphores or locks.

For PHP-FPM, inspect the pool configuration and its status page if enabled. Look for a pool that consistently reaches its maximum number of active processes or queues requests.

For database-backed applications, measure how long requests wait to acquire a connection, not just how long the query takes after execution begins.

Increasing the pool size can help in some cases, but it can also overwhelm the database. First determine whether the pool is undersized or whether connections are being held for too long.

  1. Check network latency and external dependencies

If memory, disk, and database performance all look normal, investigate the network.

An application may depend on an external API, an internal microservice, DNS resolution, or a remote database. A slow dependency can hold up the entire request while the local CPU does very little work.

For a quick connectivity test, you can use:

curl -sS -o /dev/null \
  -w 'DNS: %{time_namelookup}s\