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 withCache-Control: privateorno-store, or aSet-Cookieheader.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_statementset to250mslogs every statement that takes 250 ms or longer. In MySQL, the slow query log is off by default and itslong_query_timethreshold 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.
