Keep apps and templates loading fast. Read when adding a data model, a list/read action, a page or sidebar that loads data, or when something loads slowly. Covers column projection, indexing hot-path queries, avoiding N+1 and round-trip waterfalls, cheap polling, and not recomputing on every read.
71
88%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Treat every list, every read, and every page load as a latency budget. Two things dominate it: how much data crosses the wire, and how many round-trips and table scans it takes. On a hosted/serverless SQL backend each query is a network round-trip, and an unindexed filter scans the whole — often shared and growing — table. So default to projected columns, indexed hot-path queries, and parallel/batched fetches. These rules are provider-agnostic: they hold on SQLite, Postgres, or any managed SQL backend.
This skill is about the data and load path. See the storing-data skill for the schema
and migration mechanics it references, and the real-time-sync skill for how updates
already reach the UI without polling.
SELECT * on a listA list/index query should select only the columns the list actually renders.
Never return heavy columns in a list: large JSON/text blobs such as
document bodies, rendered HTML, config/layout/spec/data/tracks,
tool results, or base64 attachments. Pulling them for every row is the single
most common cause of a slow list.
Heavy/full columns belong on the single-item GET/detail path only.
Need a preview from a big column? Select a truncated substring at the DB, not the whole column — and it stays portable:
// Drizzle — project, and truncate the heavy column for the preview
const rows = await db
.select({
id: docs.id,
title: docs.title,
updatedAt: docs.updatedAt,
// substr/length work on both SQLite and Postgres
preview: sql<string>`substr(${docs.content}, 1, 400)`,
})
.from(docs)
.where(accessFilter(docs, docShares))
.orderBy(desc(docs.updatedAt));After narrowing the projection, update the row mapper and its return type so a dropped column is provably unused on the list path. If the list genuinely renders a heavy column (a thumbnail, an inline preview the UI shows), keep it — don't break behavior to chase a payload win.
Indexes are added through the versioned migration array in
server/plugins/db.ts as CREATE INDEX IF NOT EXISTS … — not through a
schema-level index() helper (the framework applies indexes via migrations; see
the storing-data skill). Add an index for any column a hot query filters or sorts
on. The recurring ones:
(owner_email, org_id, <the list's ORDER BY column>).
Access scoping filters by owner/org and lists sort by updated_at/created_at.{resource}_shares) → (resource_id, principal_type, principal_id).
Access checks run correlated EXISTS subqueries against these on every list.responses.form_id,
comments.parent_id, an events log's *_id) → index the FK, plus its sort
column when the children are ordered. An unindexed FK means a full scan of the
child table on every parent open. A foreign-key reference does not create an
index automatically — add it explicitly.WHERE, e.g. (owner_email, status)
or (status, <sort>).Keep index DDL dialect-agnostic and idempotent:
CREATE INDEX IF NOT EXISTS forms_owner_org_updated_idx ON forms (owner_email, org_id, updated_at)No DESC, no partial WHERE, no provider-specific syntax — it then runs on
SQLite and Postgres alike, is safe to re-run, and applies on next startup.
Indexes mostly bite as data grows and on unbounded child tables (a
seq-scan of 10 rows is instant; of a shared, ever-growing log it is not), so
index the growing tables first.
inArray(child.parentId, ids) query, then group in memory.count()), never "select all rows then .length".Promise.all rather than sequential
awaits — each await is another round-trip.For provider wrappers, inspect the upstream API before building a list-then- enrich flow. Prefer the richest endpoint that can apply the real filters and return the needed associations or participants in one paginated operation. Cursor pagination is already serial; adding a serial detail/enrichment request to every page doubles its critical path. Exhaustive records belong in corpus recipes or data programs with explicit coverage, not one agent tool call per page or item.
First-class provider actions should represent one stable conceptual operation. Keep arbitrary endpoint, filter, and pagination access in the provider API substrate; do not turn a convenience action into a capability ceiling or duplicate the provider transport, auth, quota, and cache implementation.
useActionQuery / useQuery hooks in parallel; never make the
loading skeleton wait on a serial chain.real-time-sync skill (useDbSync / SSE).
Don't add an aggressive refetchInterval that re-runs a heavy list/read every
couple of seconds. If you must poll, use a wide interval and a cheap
endpoint.Every SSR HTML page and React Router .data response is one impersonal,
public shell, hard-cached at the CDN and served identically to every visitor —
logged in or not. This is the single biggest lever on first-response latency:
one shared cache entry serves the whole site instead of a per-user render on
every request. Adding private, no-store, Vary: Cookie, a session read, or
an auth branch to the SSR path defeats the cache for every visitor, not
just one.
If you're debugging a slow first response, check whether something
re-personalized the shell before concluding the render itself is slow — the
fix is client-side data loading after the shell paints, never per-user SSR.
See the authentication skill for the full model and guard:ssr-cache-shell
plus ssr-handler.spec.ts (packages/core/src/server/ssr-handler.ts) for the
enforced contract.
Never route mutation-fresh reads through SSR loader data. Data that changes
when a user acts belongs in an action, read from the client with
useActionQuery / useActionMutation and kept live by useDbSync() polling —
that path never touches the SSR shell cache. A useRevalidator() after a
mutation re-fetches .data with a plain GET and can legitimately be served the
cached copy. SSR loaders render the public shell; the client resolves anything
that must be fresh.
The one supported knob is the deployment-wide AGENT_NATIVE_SSR_CACHE env var:
unset/on keeps the default, off sends no-store, and a duration such as
30s / 5m shortens freshness. It is for deployments whose host does not purge
its CDN on deploy, or whose loaders genuinely serve mutable public data. It
changes cache duration only — cookies are still stripped before render, so
turning it off does not make SSR personalized. There is deliberately no
per-route or per-request override; that is how one visitor's payload lands in
another visitor's shared CDN entry.
view-screen summary
reads. Images, PDFs, audio/video, archives, screenshots, and base64
attachments belong in file/blob storage; SQL rows should hold URLs, asset ids,
storage keys, or opaque blob refs.substr-truncated.WHERE / ORDER BY column is indexed (owner/org/sort,
shares resource_id, child FKs, status filters) via a db.ts migration.count()..data path stays session-blind and cacheable — no private,
no-store, Vary: Cookie, or auth branch added to it.useActionQuery, not SSR loader
data.c1ee18b
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.