Web Performance on a Budget: Bundle Size, API Limits, and Database Indexes
Learn the three budgets every web app hits: bundle size, API rate limits, and database index selectivity — with 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[chrome-lighthouse]. 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[github-rate-limits]. 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[use-the-index-luke]. 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[bundlephobia] — 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. It is code-heavy on purpose: every snippet is copyable via copy and every number is reproducible with the three calculators that accompany it — the Bundle Size Impact Calculator, the API Rate Limit & Cost Calculator, and the SQL Index Selectivity Calculator. For the network that connects them, the 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[bundlephobia]. On a 1.6 Mbps 3G link that is 122 ms of transfer before first-paint; on 9 Mbps 4G it is 22 ms[grigorik-hpn]. 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[chrome-lighthouse]. The 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[webpack-code-splitting] — the default lodash import is not shaken.
Reference — popular packages (minified+gzipped)[bundlephobia]:
| 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% |
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[github-rate-limits]; OpenAI's Tier 1 is 500 per minute; Stripe's writes are 100 per second. All are token buckets[use-the-index-luke]: 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[use-the-index-luke].
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[github-rate-limits].
The 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[postgres-indexes]. 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[use-the-index-luke].
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[use-the-index-luke].
| 100,000 (unique) | 1 | 0.001% | Index |
| 1,000 | 100 | 0.10% | Index |
| 10 | 10,000 | 10% | Borderline |
| 2 | 50,000 | 50% | Seq Scan |
The 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:
| Bundle | S_gzip / 244 < 5% per new dep | Bundle Size Impact | CI 244 kB |
| API | U = D/L < 80% sustained | API Rate Limit | Alert at 80% |
| Database | S = R/N < 10% for indexed filter | SQL Index Selectivity | EXPLAIN rows |
Novato: 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 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[bundlephobia] — 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 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[webpack-code-splitting].
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[github-rate-limits], so U = 200%, T = 500 throttled/min. With 30% retry, R = 150, Deff = 1,150. The 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[use-the-index-luke]. 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 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)[postgres-indexes]. 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.
- Measure with the same tool you ship with.
esbuild --metafilevs Bundlephobia differ by minifier — pick one[webpack-code-splitting]. - Import the file, not the barrel.
lodash/getvslodashis 8× — the same holds fordate-fns/formatvsdate-fns[bundlephobia]. - Queue bursts, don't retry bursts. A
p-limitqueue atLeliminates throttling without retries[use-the-index-luke]. - Check cardinality before indexing.
COUNT(DISTINCT col)< 100 on 100k rows → composite, not single-column[postgres-indexes]. - Read
EXPLAINrows, not just the index list.rows = S×Ntells you the selectivity the planner actually used[postgres-indexes]. - Version your budgets today. Keep the three thresholds (244 kB, 80% API, 10% selectivity) in a
budgets.jsonorlighthouse-budget.jsonso CI and humans share the same numbers — drift happens when the budget lives only in a comment.
- ❓ What is the 244 kB bundle budget?
- ✅ 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.
- ❓ Gzip or brotli for budgeting?
- ✅ Gzip for the conservative gate (all CDNs support it), brotli for the optimistic check (most modern CDNs serve brotli, ~15 percent smaller).
- ❓ When should I retry a 429?
- ✅ Retry 20–30 percent once with exponential backoff starting from the Retry-After header. Retrying 100 percent on an overloaded bucket just re-throttles.
- ❓ When does an index get ignored?
- ✅ 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.
- ❓ Can I fix low selectivity with an index?
- ✅ 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.
- ❓ How do I know my API's limit?
- ✅ 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.
- ❓ Do I need all three calculators for every PR?
- ✅ 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.
- ❓ Where do I learn more about each budget?
- ✅ Bundle: webpack code-splitting guide and Bundlephobia; API: GitHub/OpenAI rate-limit docs and token-bucket article; Database: Use The Index Luke and PostgreSQL index docs.
References
- [1]Bundlephobia. Find the cost of adding a npm package to your bundle.
- [2]webpack. Code Splitting Guide.
- [3]Chrome for Developers. Lighthouse Performance Scoring.
- [4]GitHub Docs. Rate limits for the REST API.
- [5]Winand, Markus. Use The Index, Luke! A Guide to Database Performance.
- [6]PostgreSQL Documentation. Indexes.
- [7]Grigorik, Ilya. High Performance Browser Networking. O'Reilly Media.Buy on Amazon
UnByte — Independent Software Engineering
All reference data cites its sources — Editorial policy
