# Web Performance on a Budget: Bundle Size, API Limits, and Database Indexes

> Source: <https://dev.to/apeder/web-performance-on-a-budget-bundle-size-api-limits-and-database-indexes-44c6>
> Published: 2026-08-25 05:09:22+00:00

*The three budgets every web app hits: bundle size, API rate limits, and database index selectivity. Learn the formulas, thresholds, and PR checklist.*

Every web app has three budgets, and only one of them lives in your code. The bundle budget lives in the browser — 244 kB gzip is where Lighthouse starts penalizing Time to Interactive. The API budget lives at the provider — 5,000 requests per hour on GitHub, 500 per minute on OpenAI Tier 1, 100 writes per second on Stripe. The database budget lives in the planner — an index with 0.5 selectivity will be ignored and the query will seq scan 50,000 rows. Exceed any of the three and the user pays: slower paint, `429 Too Many Requests`

, or a query that times out.

The budgets are linked by the same habit that breaks them: averaging. A bundle that is 71 kB `lodash`

full on average is 7.2 kB `lodash/get`

when imported correctly — the average hides the choice. An API that is 20 requests per minute on average is 800 per minute during a burst deploy — the average hides the spike. A table where `status`

has two values has selectivity 0.5 on average but 0.95 for `active`

and 0.05 for `pending`

— the average hides the skew.

This guide makes the three budgets explicit, with the formulas, the thresholds, and a single PR checklist that covers frontend, backend, and database. Every number is reproducible with the three calculators that accompany it — the [Bundle Size Impact Calculator](https://notacalculator.com/calculator/bundle-size-impact-calculator), the [API Rate Limit & Cost Calculator](https://notacalculator.com/calculator/api-rate-limit-cost-calculator), and the [SQL Index Selectivity Calculator](https://notacalculator.com/calculator/sql-index-selectivity-calculator). For the network that connects them, the [Bandwidth Calculator](https://notacalculator.com/calculator/bandwidth-calculator) sizes the pipe.

The smallest budget is the one the user downloads. A parsed package of 71.0 kB `lodash`

full is 24.4 kB gzip and 20.1 kB brotli. On a 1.6 Mbps 3G link that is 122 ms of transfer before `first-paint`

; on 9 Mbps 4G it is 22 ms. The same utility as `lodash/get`

is 7.2 kB parsed, 2.9 kB gzip, 15 ms on 3G — an 8× saving from a one-line import change.

The formula is linear:

where B is Mbps and RTT is round-trip in seconds. The budget share is `S_gzip / 244 × 100`

. The [Bundle Size Impact Calculator](https://notacalculator.com/calculator/bundle-size-impact-calculator) evaluates all three plus brotli.

**Code — the 8× mistake and the fix:**

`import _ from 'lodash'; // 71.0 kB parsed`

`import get from 'lodash/get'; // 7.2 kB parsed`

`import { get } from 'lodash-es'; // tree-shaken, 7.2 kB`

Bundlers like webpack only tree-shake `lodash-es`

and named ESM imports — the default `lodash`

import is not shaken.

**Reference — popular packages (minified+gzipped):**

| Package | Parsed (kB) | Gzip (kB) | % of 244 kB |
|---|---|---|---|
| lodash (full) | 71.0 | 24.4 | 10.0% |
| lodash/get | 7.2 | 2.9 | 1.2% |
| moment | 66.5 | 19.8 | 8.1% |
| date-fns | 19.0 | 5.8 | 2.4% |
| react-dom | 130.0 | 42.0 | 17.2% |

*Gzip sizes — single import choice moves 21.5 kB (107 ms on 3G). Replace moment with date-fns and save another 14 kB (70 ms).*

**Tip:** set a CI budget of 244 kB gzip total, 130 kB per route, and paste the calculator's `Budget%`

into the PR description — future reviewers see the cost without rebuilding.

If the bundle budget is about bytes, the API budget is about tokens per minute. GitHub's 5,000 per hour is 83.3 per minute; OpenAI's Tier 1 is 500 per minute; Stripe's writes are 100 per second. All are token buckets: refill at a fixed rate up to a burst capacity. You can burst to capacity instantly, but sustained throughput cannot exceed refill.

Let L = limit per minute, D = demand per minute, r = retry fraction, p = price per request:

Monthly cost (30-day month, 43,200 minutes):

At $2 per 1K ($0.002 per request), 500 per minute limit with 800 per minute burst and 30% retry gives U = 160%, T = 300 throttled/min, R = 90 extra/min, and Deff = 890/min during the burst — $3.56 for two minutes, or $76k per month if sustained. The fix is not more retries but a queue that smooths the burst.

**Code — queue instead of retry:**

`import pLimit from 'p-limit'; const limit = pLimit(10); // 10/min`

`await Promise.all(urls.map(url > limit(() => fetch(url))));`

Batch where the API allows it — GitHub GraphQL and Stripe batches turn N requests into 1.

The [API Rate Limit & Cost Calculator](https://notacalculator.com/calculator/api-rate-limit-cost-calculator) evaluates `U`

, `T`

, `R`

, and `C_month`

for any per-second/minute/hour limit.

The database budget is the most misread. Selectivity `S = R / N`

(rows returned / total rows) decides if the planner uses the index. At `S = 0.5`

(50% of rows) the index is ignored; at `S = 0.001%`

(1 row) it is always used. The threshold is around 5–20% with default costs.

If `C_idx < C_seq`

the planner chooses index scan. For `status = 'active'`

on 100k rows where 95k are active, `S = 0.95`

, `C_idx = 380k`

vs `C_seq = 100k`

— seq scan wins. For `email = 'a@b.com'`

with one row, `S = 0.00001`

, `C_idx = 4`

vs `100k`

— index wins 25,000×.

**Code — check before indexing:**

`SELECT COUNT(DISTINCT status) FROM users; -- C`

`EXPLAIN SELECT * FROM users WHERE status='active'; -- rows, cost`

If `C < 100`

on 100k rows, a single-column index on that column will be ignored for most values — use a composite `(status, created_at)`

instead.

| Distinct (C) | Rows/value | Selectivity | Planner |
|---|---|---|---|
| 100,000 (unique) | 1 | 0.001% | Index |
| 1,000 | 100 | 0.10% | Index |
| 10 | 10,000 | 10% | Borderline |
| 2 | 50,000 | 50% | Seq Scan |

*Selectivity % (log) — unique is 0.001% (always indexed), 2 distinct 50% (never alone).*

The [SQL Index Selectivity Calculator](https://notacalculator.com/calculator/sql-index-selectivity-calculator) reports `S`

, efficiency `1−S`

, and the verdict with cost.

A single PR that adds a dependency, a new API call, and a migration can bust all three budgets at once. Check them together:

| Budget | Check | Tool | Gate |
|---|---|---|---|
| Bundle |
`S_gzip / 244 < 5%` per new dep |
|

`U = D/L < 80%`

sustained`S = R/N < 10%`

for indexed filter*Begginer:* paste the three calculator outputs into the PR description — reviewers see the numbers without pulling the branch. *Senior:* add the three gates to CI — `bundle`

via Lighthouse CI, `API`

via `X-RateLimit-Remaining`

header check, `index`

via `EXPLAIN`

in migration tests. *Sensei:* make the checklist a required GitHub PR template so every service that ships JS, calls an API, and migrates a table is measured the same way.

For the network that connects them, the [Bandwidth Calculator](https://notacalculator.com/calculator/bandwidth-calculator) converts `S_gzip`

to wall-clock time on 3G/4G.

A real pull request that adds a dashboard with a chart, an analytics API call, and a new filter illustrates how the budgets interact.

**The PR:** Add `recharts`

for a new `RevenueChart`

component, fetch `/api/revenue?range=30d`

on page load, and add `WHERE status = 'active' AND region = 'EU'`

to the revenue query.

**Bundle check:** `recharts`

is 130 kB parsed, 42 kB gzip — 17.2% of the 244 kB budget. The existing bundle is 180 kB gzip, so the new total is 222 kB (91%). The [Bundle Size Impact Calculator](https://notacalculator.com/calculator/bundle-size-impact-calculator) shows 42 kB + 3G 210 ms. The fix is code-splitting: move `RevenueChart`

to `React.lazy`

so the initial route stays at 180 kB and the chart chunk loads on demand.

**API check:** The dashboard polls every 30 seconds, so 2 requests per minute per user. With 500 concurrent users, D = 1,000 per minute. The API limit is 500 per minute, so U = 200%, T = 500 throttled/min. With 30% retry, R = 150, Deff = 1,150. The [API Rate Limit & Cost Calculator](https://notacalculator.com/calculator/api-rate-limit-cost-calculator) shows the cost and the need for a queue. The fix is caching the `GET`

for 60 seconds and batching the poll to 1 per minute via `SWR`

.

**Database check:** The new filter `WHERE status='active'`

on 500k rows where 80% are active has S = 0.80, efficiency 20% — the planner will seq scan. Adding `AND region='EU'`

where EU is 10% of rows makes the composite `S = 0.80 × 0.10 = 0.08`

(8%) — now selective enough for a composite index `(region, status)`

or `(status, region)`

depending on which column is more selective. The [SQL Index Selectivity Calculator](https://notacalculator.com/calculator/sql-index-selectivity-calculator) confirms S = 8% → Index Scan, cost 32k vs seq 500k.

The PR ships when all three checks are green: bundle under 244 kB initial, API U < 80%, and `EXPLAIN`

shows Index Scan. One budget in the red is a revert.

Budgets are not one-time checks; they drift. Bundle size grows with every `npm install`

, API load grows with every new replica, and selectivity shifts as the table fills. Track the three on the same dashboard: Lighthouse CI for bundle (`S_gzip`

trend), `X-RateLimit-Remaining`

header sampling for API (`U`

over time), and `pg_stat_user_indexes`

scans for database (`S`

per index). When any of the three crosses 80% of its budget, the next feature PR should include a budget-reducing change — a lazy chunk, a cache, or a composite index — before new functionality.

`esbuild --metafile`

vs Bundlephobia differ by minifier — pick one.`lodash/get`

vs `lodash`

is 8× — the same holds for `date-fns/format`

vs `date-fns`

.`p-limit`

queue at `L`

eliminates throttling without retries.`COUNT(DISTINCT col)`

< 100 on 100k rows → composite, not single-column.`EXPLAIN`

rows, not just the index list.`rows = S×N`

tells you the selectivity the planner actually used.`budgets.json`

or `lighthouse-budget.json`

so CI and humans share the same numbers — drift happens when the budget lives only in a comment.**Q: What is the 244 kB bundle budget?**

A: *A common Lighthouse threshold where total gzip over ~244 kB starts penalizing Time to Interactive. Use it as a reference; your product's budget may be 170 or 500 kB.*

**Q: Gzip or brotli for budgeting?**

A: *Gzip for the conservative gate (all CDNs support it), brotli for the optimistic check (most modern CDNs serve brotli, ~15 percent smaller).*

**Q: When should I retry a 429?**

A: *Retry 20–30 percent once with exponential backoff starting from the Retry-After header. Retrying 100 percent on an overloaded bucket just re-throttles.*

**Q: When does an index get ignored?**

A: *When selectivity is high — roughly above 10 percent with default costs. On 100k rows, keeping 10k rows (10 percent) is borderline; keeping 50k (50 percent) is always a seq scan.*

**Q: Can I fix low selectivity with an index?**

A: *Alone, no — a single-column index on a 2-value column is 50 percent selective. As part of a composite (status, created_at) that raises cardinality, yes.*

**Q: How do I know my API's limit?**

A: *Check the docs (GitHub 5,000/hour, OpenAI 500/minute, Stripe 100 writes/second) and the response headers X-RateLimit-Limit/Remaining/Reset. The calculator handles per second/minute/hour.*

**Q: Do I need all three calculators for every PR?**

A: *No — check the budget you touch. New dep → bundle, new API call → rate limit, new WHERE → selectivity. If a PR touches all three, check all three.*

**notAcalculator** provides free online calculators and educational guides covering finance, fitness, mathematics, and everyday calculations.
