docs(app-skills): add performance guidance to app development skill (#10937)

Cover frequently called endpoints, read-only transactions, list response size, client refresh, bounded concurrency, background work, leases and realistic measurement.
This commit is contained in:
YANG QIA
2026-10-16 07:44:57 +08:00
committed by GitHub
parent bc1facd3f6
commit e7ba6071a5
6 changed files with 34 additions and 2 deletions
@@ -0,0 +1,5 @@
---
'@nocobase/app-skills': patch
---
Add performance guidance to the application development Skill: frequently called endpoints, transactions for reads, list response size, client refresh and subscriptions, bounded concurrency, background work, timeouts and leases, and measuring on a realistic database and data volume.
@@ -84,7 +84,7 @@ Read the page for the task in front of you. Do not read all of them.
| Anything under `client/`: pages, routes, components, styling, forms, API calls, copy, frontend tests | [frontend workflow](references/frontend/ui-workflow.md), which routes to the [frontend handbook](references/frontend/frontend-dev.md), the theme reference or the worked example |
| Add an API endpoint, a webhook, or a callback; authenticate and authorize it | [server routes](references/server-routes.md) |
| Name an endpoint, shape its response or errors, validate its input, paginate a list, document it in the API document | [HTTP API design](references/http-api.md) |
| A list, dashboard or chart endpoint, or keeping one live: read with a fixed number of queries, refresh only what changed | [server routes](references/server-routes.md#lists-and-statistics-a-fixed-number-of-queries) and [client API requests](references/frontend/references/api.md#keeping-lists-and-charts-live) |
| A list, chart or polled endpoint, or keeping one live: read it with a fixed number of queries, refresh only what changed | [server routes](references/server-routes.md#lists-and-statistics-a-fixed-number-of-queries) and [client API requests](references/frontend/references/api.md#keeping-lists-and-charts-live) |
| Query or write data, resolve the database, work with transactions | [database and data access](references/database-and-data.md) |
| Create a table, alter a column, add an index, write required initial data | [migrations and seeds](references/migrations.md) |
| Switch the database, register a dialect, add a second connection | [database connections](references/database-connections.md) |
@@ -307,6 +307,11 @@ When realtime events refresh what a page shows:
- Recompute a chart's figures at most every few seconds while it is visible, and not at all while the page is hidden; or update only the figure the event affects.
- Read nothing for a page that is not shown; read it when it is shown again.
- A component that needs one small fact reads the small endpoint that answers it, not the list that happens to contain it.
- Keep realtime subscriptions in one place, such as the page or a hook it owns, rather than one per row or card, and subscribe only to the topics the page shows.
- When a refresh is triggered while a request for the same data is still in flight, usually let it finish and read once more afterwards rather than aborting it and sending it again; repeated aborts under a stream of events can mean no request ever completes. Aborting is still right when the parameters change or the component unmounts, as above.
- Skip fetching while the page is hidden (`document.visibilityState`), and refresh once when it becomes visible again.
- Keep request keys stable: build them from the parameters that decide the data, not from values that change on every render or tick, such as the current time, or each change starts a new request.
- Be careful with data loaded by components mounted on every page, such as navigation or a header badge: they run on every page and in every open tab, so they usually read a small fact rather than a list.
The server side of these reads is in [server routes](../../server-routes.md#lists-and-statistics-a-fixed-number-of-queries).
@@ -238,7 +238,8 @@ Do not write a helper that mutates a router passed in by its caller. Returning a
A route that answers many rows or figures reads them with a fixed number of queries, whatever the row count. The database API is in the `nocobase-db` Skill.
- Read each kind of data once for all rows: one query with `IN (...)` over the row ids, then join the results in memory. Never call a per-item function or Repository read inside a loop or a `Promise.all` over rows.
- A list row carries only what the row shows. Expensive per-item work — diagnostics, "why is this waiting", full histories — belongs to the item's own detail endpoint.
- When per-item database work genuinely cannot be batched, bound its concurrency to a small number rather than starting one query per item at once: an unbounded `Promise.all` can exhaust the connection pool and stall every other request, not just this one.
- A list row carries only what the row shows. Expensive per-item work — diagnostics, "why is this waiting", full histories — belongs to the item's own detail endpoint, which the client usually reads when a detail view opens. When you add a field to a list response, check how much it adds to the response size across a full page of rows.
- A component that needs one small fact, such as whether the viewer follows an item, gets a small endpoint answering that fact rather than reading a whole list or overview.
- Aggregate in the database: `COUNT`, `SUM`, `GROUP BY` through the Repository's `count`/`groupBy` or the query builder. Never load rows to count or sum them in JavaScript.
- One chart is one request, so charts load, fail, refresh and cache on their own. Each chart's request computes its figures with a fixed number of aggregate queries: one `GROUP BY` day, not a query per day or per item.
@@ -248,6 +249,14 @@ A route that answers many rows or figures reads them with a fixed number of quer
The browser side — refreshing such a list or chart from realtime events — is in [client API requests](frontend/references/api.md#keeping-lists-and-charts-live).
## Frequently called endpoints
An endpoint called on a timer — polling, a heartbeat, a status badge every open page reads — costs its per-call price times its call frequency across every client, so a few milliseconds per call can add up to a large share of the database's time. Estimate both before writing one.
- Usually keep it read-only, without writes or a transaction. Work that has to write, such as recording last-seen times or cleaning up, can often move to a less frequent place: a scheduled job (see [services and jobs](services-and-jobs.md#work-that-runs-on-a-schedule)), or a write only when the value actually changed.
- Read-only code usually needs no transaction, here or anywhere else. A transaction holds a connection for its whole duration and can make reads cost more; use one only for writes that must succeed or fail together.
- Prefer answering a small fact over a list or overview, as the section above describes, and consider caching it briefly when many clients ask the same question.
## Keep the layers apart
Routes handle HTTP: parsing input, checking permission, shaping the response and status. Domain logic goes in a service under `server/providers/` — see [services and jobs](services-and-jobs.md). A service should not read a Hono context or decide status codes.
@@ -281,3 +290,4 @@ In React components and custom Hooks, use `useApiClient()` from `@nocobase/app-c
- Every `/api` route is declared: after `app.start()`, `findUndeclaredApiRoutes(app)` is empty, and the route appears in `GET /api/swagger` with its `operationId`. See the Verify section of [HTTP API design](http-api.md#verify).
- A public route rejects missing and invalid signatures, and handles duplicate delivery.
- A list or statistics route makes the same number of queries for ten rows as for a thousand.
- A frequently called route stays read-only and outside a transaction, or its writes have a reason to be there.
@@ -222,6 +222,16 @@ await app.container
See the `@nocobase/queue` README for the full contract.
## Keep background work light
Background and maintenance work shares the database and its connection pool with the requests users are waiting on, so it should usually not block them.
- Maintenance usually should not pause the main flow. Prefer work that runs beside it over a step that holds requests or other jobs until it finishes.
- Time-box long work or do it in batches: process a bounded number of rows per run or per transaction, and continue in the next run, rather than one long transaction or one query over a whole table.
- When per-item work queries the database, batch it or bound its concurrency, as [server routes](server-routes.md#lists-and-statistics-a-fixed-number-of-queries) describes for lists.
- When the inputs have not changed, consider caching the result (`cachingToken` from `@nocobase/app-server/caching`) or skipping the run rather than recomputing it.
- Leave headroom in timeouts and leases. While a worker is alive, give it enough time to finish, for example by renewing its lease; a missed heartbeat is usually a more reliable sign that a machine is gone than a tight timeout, which can declare a slow but healthy worker dead and run its work twice.
## Verify
- The service resolves from the token and behaves correctly in isolation.
@@ -142,6 +142,8 @@ Then verify the affected behavior, selecting only the applicable steps below. Gr
- Confirm the endpoint's responses for signed-out, unpermitted, and permitted callers.
- Confirm `pnpm nocobase db apply` applies cleanly.
When a change is about performance, or adds a list, a statistic or a frequently called endpoint, measure it in a realistic environment: the database the application is actually deployed on, with a realistic data volume. A small data set hides slow queries and missing indexes, and SQLite's costs differ from those of a database server, so a timing on one says little about another. After release, consider each endpoint's call count multiplied by its duration rather than a single measurement: a cheap endpoint called constantly can cost more than a slow one called rarely.
## Reporting
Say what you ran, what passed, and what you did not run. If you could not verify something — no test database, a flow needing real credentials — say so rather than implying it was checked.