/back-to-blog
PerformanceDecember 15, 20255 min read

Why Is My Website Slow? Four Hidden Bottlenecks

Images compressed and CSS minified, and the site is still slow? Here are four causes that image tools miss, with the commands to find and fix each one.

A

Acefina

Performance · Infrastructure · Security

Abstract illustration of orange and blue light trails curving towards a city skyline at night

If you've compressed your images and minified your CSS and the site is still slow, the delay may be happening before the browser receives any HTML. That shows up as a slow Time to First Byte (TTFB). web.dev rates a TTFB of 0.8 seconds or less as good and anything over 1.8 seconds as poor. Check yours in PageSpeed Insights, then work through the causes below. For the full set of speed targets, see how fast a website should load.

1. Database queries without indexes

When a column you filter or join on has no index, the database has to read every row in the table to find matches. In PostgreSQL, EXPLAIN ANALYZE runs the query and shows how it was executed and how long it took:

-- PostgreSQL
EXPLAIN ANALYZE SELECT * FROM posts WHERE author_id = 123;
-- "Seq Scan on posts" means the whole table was read

CREATE INDEX idx_posts_author ON posts (author_id);

EXPLAIN ANALYZE SELECT * FROM posts WHERE author_id = 123;
-- Look for an "Index Scan" or "Bitmap Index Scan" using idx_posts_author
-- and compare the "Execution Time" line with the first run

EXPLAIN ANALYZE really executes the statement, so wrap an UPDATE or DELETE in BEGIN; and ROLLBACK; when you test it.

Foreign keys are a common gap. The PostgreSQL documentation states that declaring a foreign key "does not automatically create an index on the referencing columns". MySQL's InnoDB engine does create that index automatically if one doesn't exist. On PostgreSQL, columns such as author_id or order_id need their own index if you query by them.

2. N+1 queries

An ORM makes it easy to load a list and then fetch a related record for each item, one query at a time. Fifty posts become 51 queries. Each one is fast, so none of them shows up as slow on its own, but together they add up on every page load.

This example uses Sequelize v6:

// Association, defined once
Post.belongsTo(Author, { as: 'author', foreignKey: 'authorId' });

// N+1: one query for the posts, then one more query per post
const posts = await Post.findAll();
for (const post of posts) {
  const author = await Author.findByPk(post.authorId);
  console.log(post.title, author.name);
}

// Eager loading: one query with a LEFT OUTER JOIN
const postsWithAuthors = await Post.findAll({ include: 'author' });
for (const post of postsWithAuthors) {
  console.log(post.title, post.author.name);
}

Sequelize's eager loading guide confirms that an include runs as a single query and uses a LEFT OUTER JOIN by default. Other ORMs have the same tool under different names, such as includes in Rails and select_related or prefetch_related in Django.

Because each query is quick, a slow query log usually won't catch N+1. To spot it, log every query in development and count how many run for one page. If you would rather have someone read the codebase for patterns like this, a fixed-price code audit ranks what it finds by risk. In PostgreSQL, setting log_min_duration_statement = 0 logs the duration of every statement.

3. A CDN that isn't caching your pages

Putting a CDN in front of a site doesn't mean your pages are cached. Cloudflare's documentation states that it "does not cache HTML or JSON by default". It decides by file extension, so images, CSS and JavaScript are cached while every page request still goes to your server.

Check the cf-cache-status response header in DevTools or with curl -I:

  • DYNAMIC: Cloudflare didn't consider the response cacheable, which is the default for HTML.
  • BYPASS: a Cache Rule made it eligible, but the origin response blocked caching, for example with Cache-Control: private or no-store, or a Set-Cookie header.
  • HIT: the page came from Cloudflare's cache.

The cache status reference lists the other values. To cache pages that are the same for every visitor, such as blog posts and marketing pages, add a Cache Rule for those paths and make sure your server doesn't set cookies on them. Keep logged-in and personalised pages out of the rule.

4. Third-party scripts

Tag managers, chat widgets, A/B testing tools and ad scripts all run on your visitors' devices and compete with your own code. web.dev's guide to loading third-party JavaScript recommends: "Always use async or defer for third-party scripts, unless the script is necessary for the critical rendering path." It also warns that async alone doesn't fix every slow script.

To measure what a script costs you, block it in Chrome DevTools with the Network request blocking tab, reload the page and compare the Performance panel recordings. A script that adds little and costs a lot is a candidate for removal or for loading only on the pages that need it.

Finding the bottleneck you actually have

Measure before you change anything:

  • Turn on the database's slow query log. In PostgreSQL, log_min_duration_statement set to 250ms logs every statement that takes 250 ms or longer. In MySQL, the slow query log is off by default and its long_query_time threshold defaults to 10 seconds, so lower it when you switch it on.
  • Use an APM tool such as New Relic or Datadog for server-side requests. A New Relic transaction trace records the function calls, database calls and external calls inside a slow request.
  • Use the Chrome DevTools Performance panel for what happens in the browser after the HTML arrives.

Acefina has optimized 500+ websites. If you'd like a second pair of eyes on yours, ask for a free review through our speed optimization service. If search visibility worries you more than speed, start with a technical SEO audit of crawling and indexing.

Need help with your infrastructure?

Let's turn this article into action on your site.

If your site needs faster performance, better security, or streamlined DevOps, we can help you plan and deliver.