We made our dashboard 35% faster without touching a single query

Our API answered in 40 ms. Our dashboard still took over a second to open. The difference was spent crossing the Atlantic, and four small changes won a third of it back.

Where the second went

Our servers are in Frankfurt and we're in Halifax. A round trip between them takes about 140 ms, and no code can make light go faster.

So the useful question isn't "how fast is each request?" but "how many times does the page cross the ocean?" Every request that waits for another one to finish pays the 140 ms again. Shaving 20 ms off a query saves 20 ms. Removing one crossing saves 140.

Before · 10 round trips Connect HTML JS bundle Site details Stats Connect: 2 round trips HTML: 1 round trip 311 KB JS bundle: 5 round trips Site details: 1 round trip Stats: 1 round trip 311 KB, uncompressed After · 7 round trips Connect HTML JS bundle Site details Stats Connect: 2 round trips HTML: 1 round trip 97 KB gzipped JS bundle: 3 round trips Site details: 1 round trip Stats: 1 round trip, alongside the Site details 97 KB, gzipped 3 fewer ≈ 420 ms 012345678910 Round trips, 140 ms each
Counted in round trips: compression took two off the bundle, and starting the stats alongside the Site's details took one more.

Measure with distance, not on localhost

On localhost a round trip costs nothing, so a staircase of requests looks fine. Add some distance:

  • Chrome DevTools: in the Network tab's throttling menu, choose Add… and make a profile with 140 ms of latency and no bandwidth limit.
  • Linux: sudo tc qdisc add dev lo root netem delay 70ms adds 70 ms each way. Undo it with sudo tc qdisc del dev lo root, or spend tomorrow wondering why everything is slow.

Then look for staircases in the waterfall: requests that only start once another finishes. We found four.

1. Compress the bundle

Our 311 KB JavaScript bundle went out uncompressed. Gzipped, it's 97 KB. A new connection only sends about 14 KB before waiting to hear it arrived (TCP slow start), so that's about two fewer round trips, not just fewer bytes.

The files are embedded in our Go binary and never change, so we compress them once at startup and serve those bytes to any browser that accepts gzip. Simplified:

var gzippedAssets = sync.OnceValue(func() map[string][]byte {
	out := map[string][]byte{}
	fs.WalkDir(distFS, "dist/app/assets", func(name string, d fs.DirEntry, err error) error {
		if err != nil || d.IsDir() {
			return err
		}
		raw, _ := fs.ReadFile(distFS, name)
		var b bytes.Buffer
		zw, _ := gzip.NewWriterLevel(&b, gzip.BestCompression)
		zw.Write(raw)
		zw.Close()
		out[name] = b.Bytes()
		return nil
	})
	return out
})

Behind a CDN that's supposed to do this for you? Check that it does: curl -sI -H 'Accept-Encoding: gzip' https://your-site/app.js should show content-encoding: gzip.

2. Start independent requests together

The page fetched the Site's details, rendered, and only then did the dashboard ask for stats. But the stats only needed the Site's ID, which had been in the URL all along. Now both start at once:

const site = useAsync(() => api<Site>(`/api/sites/${siteId}`), [siteId])
useEffect(() => prefetchDashboard(`/api/sites/${siteId}`, query), [siteId])

When the dashboard mounts, its requests are already on their way, and it waits for those instead of sending duplicates. A parent fetches, renders a child, then the child fetches: it's the most common waterfall in single-page apps. If the child doesn't need the parent's answer, don't make it wait for one.

3. Don't ask again for what you just saw

Switching from "Last 30 days" to "Last 7 days" and back refetched everything. Now a view under a minute old shows instantly with no request, and an older one shows at once while it refreshes. That's The 30-line cache that made our dashboard feel instant.

4. One query, not one per Goal

The Goals panel queried Postgres once per Goal, one after another. Now it's a single query: 8 database trips down to 1.

The result

At 140 ms of latency, the demo dashboard's cold load went from 1.07 s to 0.70 s, and going back to a view you've already seen became instant. No query got faster. The page just stopped standing around waiting.

Open the demo, open DevTools, and watch the waterfall.