Performance · July 2026

From 30 seconds to 2
How we fixed the cold boot

A single 4.6MB database query was running on every anonymous page load, blocking the map from rendering. Removing it cut our cold time-to-map from 22–32 seconds to about 2 seconds. This is how we found it.

By Olli · 3 July 2026 · 6 min read

The symptom

When someone opened Fishare for the first time — no cached assets, cold CDN edge, fresh browser — the map took somewhere between 22 and 32 seconds to appear. Sometimes longer on slow mobile connections.

We knew this was bad. Anglers open the app on Friday night to check Saturday morning conditions. At 25 seconds, most had given up and opened Windy in another tab before the map rendered. A product that is invisible for half a minute is invisible.

27s
median cold boot before
~2s
median cold boot after
4.6MB
query removed from anon boot
136109KB
main.js bundle (gz)

The cause

Our app has a catches feed — a public map layer that shows recent catches logged by users who opted in. It was fetching the full dataset of every public catch on every page load, including completely anonymous visitors who had no account and had never seen a catch in their life.

The specific query: a PostgREST call fetching all public catches ordered by recency, with no hard row limit per request. On a dataset of thousands of rows, this reliably returned 4.6MB of JSON over the wire before anything else rendered.

The boot path was serialised: the app would not render the map until the full boot sequence completed, and the boot sequence included this query as a blocking step. So the first map frame waited on 4.6MB of catch data that no anonymous visitor ever scrolled to.

The query had been added incrementally — it started as a small dataset with a sensible row count, and over months of catch logging the payload grew past the threshold where the serialised boot approach stopped working. No single commit made it bad; it got bad slowly.

The fix

Step 1: remove the catches query from anonymous boot

The catches feed does not exist on the anonymous landing page. The query was running speculatively — the app loaded it in case the user scrolled down to the map section. We moved the fetch to trigger only when the catches layer is actually activated by the user.

This was a one-line guard but it required understanding which component owned the fetch and whether removing it from boot would break the logged-in experience. It did not — logged-in users who open the catches layer get the data on demand, which is the right behaviour anyway.

Step 2: kill the boot serialisation

Even after removing the catches query, the map was waiting on several other sequential async steps before the first basemap tile rendered. We broke the serialisation: the basemap now renders immediately from the tile provider without waiting for any app-level data. Non-critical data (bite scores, overlay layers, spot metadata) loads in parallel after the first frame.

The visible result is that the map appears at basemap zoom within about 2 seconds on a cold load, and the overlay data fades in over the following 1–2 seconds. The user sees something immediately rather than a loading state.

Step 3: lazy-load the landing page component

The landing page (LandingV2) was bundled into the main entry chunk even for users who land directly on a spot page, the forecast page, or the map. It is a large React component tree with marketing copy that anonymous users who deep-link to a spot page never see.

Moving it to a dynamic import reduced the main.js bundle from 136KB to 109KB (gzip). That is 27KB less to parse and evaluate on every cold boot regardless of which page the user lands on.

Why now

We had other higher-priority work in the queue — the HSI habitat forecast, the 7-day playback, catch photo storage. Performance work has a tendency to slip because the product still works, just slowly.

What moved it up the priority list was looking at the conversion funnel. The signup rate from first visit is the number we care about most at this stage, and a 27-second cold boot means most first-time visitors never reach the signup prompt. You cannot optimise a conversion rate on users who never saw the product.

The framing that made it concrete: a 27-second cold boot is not a "performance issue" — it is a CRO issue. If 90% of first-time visitors bounce before the map renders, the signup rate denominator is only the 10% who waited. Fix the bounce and you get 9x more users in the funnel, even before touching the signup flow itself.

What we are watching

We shipped these changes on 1–2 July 2026. The baseline observation window is 4 weeks — performance improvements affect every visitor regardless of acquisition channel, so the signal should emerge slowly across all sources over that time. We will check the aggregate signup rate at the Monday 3 August audit.

At current traffic volumes, a one-week comparison would be noise. The 4-week window gives enough sessions to distinguish signal from natural weekly variance.

The one metric we expect to move fastest is the direct bounce rate — users who open the app, see a blank screen for too long, and close the tab before the map renders. That number should drop measurably within the first week even at low traffic.

The lesson

The query that caused this was a reasonable thing to add at the time it was added. A small catches dataset with fast load time, fetched speculatively to make the feed feel instant when the user scrolled to it. The mistake was not patrolling it as the dataset grew.

For any app with a data-loading phase before first meaningful render: measure the payload size weekly, not just at initial ship. Datasets that start at 200KB reliably grow past 4MB over six months of user activity. The boot path that was fine at launch will quietly become broken over time if nobody is watching.


Try the fast version

Map loads in about 2 seconds. Bite forecast from a neural net trained on 2.5M catches. Free, no account required.

Open Fishare