ttushar.

Three queries were using 98% of the database

Loyallist has an analytics side. Customer websites run a small tracking script, and dashboards show who visited and what they did. In October those dashboards were taking up to 18 seconds to load, and the database server was busy day and night.

I was sure the dashboards were the problem, until I read the query logs. Three small queries from the tracking script were using 98% of all database time. Every dashboard together used less than 1%.

After the fix, the busiest query went from 1.6 seconds to 29 milliseconds. Those three queries now cost the database about 3 minutes of work a day, down from about 3 hours. No data changed, and every API response stayed exactly the same. Flip the switch:

Before and after

1,622 ms

Loading the tracking settings, on every page view

1,326 ms

Saving how long a visitor stayed

~3 h

Database time per day for the top three queries

Prod averages from the query logs: 63 hours before the change and the first 15 hours after it.

How the tracking works

Every customer website loads the tracking script. On each page view it asks for its settings and records the view. When the visitor leaves, it sends how long they stayed. The dashboards read the same tables to draw their charts.

The script runs on every page view of every customer site. A dashboard query only runs when someone opens a dashboard. So a slow script call hurts a lot more than a slow chart.

Where the time went

I took three days of production query logs, about 47,000 requests, and added up the database time for each operation. Tap or hover a slice to see what it does.

Where the database time went

Loading tracking settings

Runs on every page view of every customer site.

Share

68%

Calls

13,726

Avg time

1.59 s

Prod query logs, Oct 22 to 24, about 47,000 requests. Tap a slice.

The cause was boring. The database had 21 indexes, and almost all of them were primary keys. Not a single foreign key had an index. So every lookup by tracking code, session or page read the whole table, on every page view, for every customer.

If that sounds abstract, try it. Pick a row and search for it both ways, then drag the slider to see what happens as the table grows.

Try it: full scan vs index

Looking for row #65. Tap any row to pick another.

Rows read: 0 of 96

No index reads

95,635

Index hops, about

17

An illustration of the idea, not a benchmark. Postgres indexes are B-trees, which take even fewer hops than this.

Ground rules before touching prod

This is a live product with real customers, so I wrote down a few rules first. Prod stays read-only unless I approve a specific step, and saying yes to one step doesn't cover the next one. Only read speed changes, so every API response keeps the same shape and the same values. Indexes get built with CONCURRENTLY, which lets reads and writes keep going while they build. And every change has a rollback script that I tested locally first.

Then I set up a test bench. I restored a prod snapshot into Docker on my machine and capped it to the same CPU and memory as the real server. I replayed all 58 read operations from both apps through the API and timed each one with EXPLAIN ANALYZE. Every task also got an independent review.

The most useful part was a parity check. It runs the old query and the new query on a pile of awkward cases and fails on any difference at all.

Step 1: fourteen indexes

An index is a sorted lookup list. With one, the database jumps straight to the rows it needs instead of reading the whole table. I added 14, mostly on foreign keys and on the columns the tracking script filters by.

I tested each one before it went in. I ran all 58 reads with and without every candidate index, and two of them made a query slower, so I left those out. The build took 2 minutes with no downtime. It used about 690 MB of extra disk and made each insert a tiny bit more expensive.

Prod, average time per call

Before After
  • Load tracking settings

    1,622 ms
    29.0 ms56× faster
  • Save time on page

    1,326 ms
    3.8 ms349× faster
  • Link visit to a campaign

    396 ms
    2.0 ms198× faster
  • Close a campaign session

    363 ms
    4.3 ms84× faster
On a normal scale the after bars are drawn at their true length. Most are a few pixels wide. Switch to log scale to compare them.
OperationBeforeAfterFaster
Load tracking settings1,622 ms29 ms56×
Save time on page1,326 ms3.8 ms349×
Link visit to a campaign396 ms2.0 ms198×
Close a campaign session363 ms4.3 ms84×

Step 2: tuning Postgres did nothing

Every tuning guide says to give Postgres more memory per query and to make random reads cheaper on SSDs. So I tried four setups on the local copy, twice each, after the indexes were in.

The full set made the new visitor count 3× slower. Raising work_mem to 16 MB pushed Postgres into a plan that spilled to disk. Giving it more shared memory alone changed the total by 1.1%, and two runs with no changes at all were already 3.3% apart, so that's noise. Turning off JIT on top of it gave 6.5%, a small win I parked for later. Any of these also needs a database restart, so prod kept its defaults.

Pick a setup to see where it lands:

Settings lab

4.1 s, fasterslower, 5.2 s

Settings

  • shared_buffers=768MB
  • effective_cache_size=2GB
  • work_mem=16MB
  • random_page_cost=1.1
  • jit=off

Total

5.06 s (+9.5%)

Verdict

Slower

Total time for the replayed reads on the local copy of prod. The shaded band is the 3.3% gap between two runs with no changes at all.

Step 3: ask once, get only the answer

Some dashboard queries were slow for another reason: the question itself was clumsy. The KPI cards ran one query with seven parts, and each part repeated the same "skip these visitors" filter. The session pages downloaded every single session row, 6.3 MB of it, just to count them in JavaScript.

I moved that work into three SQL functions. Now the app sends one short request and gets back a handful of numbers.

ChangeBeforeAfterStatus
KPI cards, one function1,090 ms181 msLive
Session counts done in SQL6.3 MB download0.3 to 3 KBLive
Visitor count on the home dashboard7.0 to 9.6 s2.4 to 3.8 sLive, watching
Pre-computed page stats1,679 ms2 msHeld
Tracking view cleanup678 ms585 msDropped

Every function returns the same values as the query it replaced. That held on up to 40 test cases each, and I checked it again on live prod data during the rollout.

Two of these never shipped, and the numbers made both calls for me.

Pre-computed page stats

These made the page tables about 800× faster. The catch is that the stored copy has to be refreshed every 10 minutes, and each refresh costs about 14 seconds of database time. That's 35 to 60 minutes a day. The logs showed people opened those pages 25 times in 2.6 days, which is about 7 seconds of database time a day. On a small 2-CPU server the refresh would cost way more than it saves, so it waits until traffic grows. Play with the numbers:

Is pre-computing worth it?

Refresh cost per day34 min
Database time saved per day7 s

Costs more than it saves. It starts paying off at about 2,770 opens a day.

Uses two prod measurements: about 14 s per refresh, and about 0.7 s of database time per page open. The marker on the slider is today's traffic.

The view cleanup

The plan had a simple rule for this one: keep it only if the target query gets at least 2× faster. It got 1.16× faster, and three daily report queries got 7 to 10× slower because Postgres lost a parallel plan. So I dropped it.

Rollout: database first, code second

On this project, merging a pull request deploys it. That decided the order. Each SQL function had to exist on prod, and be registered in the API layer, before the code that calls it got merged. Otherwise the endpoint throws an error, or worse, the home dashboard quietly shows 0 visitors.

The indexes went in first, on their own. Later that day, the three functions, both app deploys and one extra index took 14 minutes, one step at a time. After every step I ran the old and new query on live data and compared the answers. Click through it:

Rollout, step by step

15:19Done

Built the 14 indexes with CONCURRENTLY, so the site kept running.

0 invalid indexes. Refreshed the table stats in 10 s.

1 / 10
Times are UTC on 24 October 2026.

What testing caught

Six problems showed up before they could reach prod. Tap a card to see what would have happened and how I fixed it.

What's still open

It's not all done. When nothing is cached in memory, the home dashboard's visitor count can still take 10 to 17 seconds. The next step is to vacuum the page view table so Postgres can answer from the index alone, and to read only page views instead of sessions too. The target is under 1 second, cold or warm.

One small, low-traffic count query also got slower after the change, from 19 ms to 224 ms. At its volume it barely matters, but I want to know why. And the pre-computed stats stay off until traffic makes them worth it.

What I'd tell myself at the start

Read the logs before you guess. I'd have started with the dashboards, and they were under 1% of the load. Measure every index, because two of mine made things slower. Don't trust tuning advice until you've tried it on a copy of your own data. And when merging means deploying, ship the database change before the code that needs it.