Skip to content

Performance · Supabase · Web Workers

The Web Worker pattern that makes per-tenant Supabase queries feel instant

Off-main-thread hydration in 60 lines of Vite

⚙️ 🧵 💨

If you've been living under a rock, hydrating a five-thousand-row tenant cache on the main thread is the silent killer of every SaaS dashboard. The query is fast. The render is slow. The villain is JSON.parse plus a synchronous map build that freezes your sidebar for 400ms while the user thinks the click did nothing.

Spoiler: you do not need to rewrite your data layer. You need one Vite worker import and a fallback. Behold.

The Setup

Vite has built-in worker support. Append ?worker to the import and Vite handles the bundling, transpilation, and module resolution. No webpack-worker-loader plugin theatre, no separate build config, no Comlink unless you genuinely enjoy proxies.

The worker file does three things — fetches the table, builds the lookup maps, computes any derived geometry, then posts the indexed object back to the main thread as one structured-clone payload.

 {
  const { url, anon, tenant } = e.data;
  const endpoint = url + '/rest/v1/suburb_cache?tenant_id=eq.' + tenant + '&select=*';
  const res = await fetch(endpoint, {
    headers: { apikey: anon, Authorization: 'Bearer ' + anon }
  });
  const rows = await res.json();

  const byName: Record = {};
  const byPostcode: Record = {};
  for (const r of rows) {
    byName[r.name.toLowerCase()] = r;
    (byPostcode[r.postcode] ||= []).push(r);
  }
  const centroid = rows.reduce((a, r) => [a[0] + r.lat, a[1] + r.lng], [0, 0])
    .map(n => n / rows.length);

  self.postMessage({ rows, byName, byPostcode, centroid });
};`}>

The Money Pattern

The main-thread loader is a singleton with in-flight deduplication and a graceful fallback. If new Worker throws (Safari with strict CSP, ancient browsers, your local file server), the same code runs on the main thread. Below 200 rows we skip the worker entirely because postMessage and structured clone cost more than the parse.

 | null = null;
let cache: HydratedCache | null = null;

export function loadSuburbCache(tenant: string): Promise {
  if (cache) return Promise.resolve(cache);
  if (inflight) return inflight;

  inflight = new Promise((resolve, reject) => {
    try {
      const w = new SuburbWorker();
      w.onmessage = (e) => {
        if (e.data.rows.length < 200) return resolve(hydrateMain(tenant));
        cache = e.data;
        resolve(cache);
        w.terminate();
      };
      w.onerror = () => resolve(hydrateMain(tenant));
      w.postMessage({ url: SUPA_URL, anon: SUPA_ANON, tenant });
    } catch {
      resolve(hydrateMain(tenant));
    }
  });
  return inflight;
}`}>

The Catch

Workers cannot share Supabase auth tokens directly. You pass the anon key and let RLS plus a tenant filter do the gating, or you mint a short-lived service-role JWT in a Netlify function and post that. Do not embed service-role keys in worker bundles. Yes really.

Structured clone also serialises everything you post back. Big Maps become plain objects on the other side. Plan for it.

The Verdict

Off-main-thread hydration is sixty lines of Vite for a 5x perceived speedup on dashboards north of 2k rows. The sidebar stays interactive, the click feels instant, and the user never sees the spinner. Do not @ me about service workers — different tool, different job.

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.