# Load testing Load testing sends a controlled amount of traffic at your app to find out how it behaves at the request rates you expect from real users. This guide covers which environment to test, how to pick a tool, how to write a test that stays inside your app's limits, and how to read the results. The examples use [Grafana k6](https://grafana.com/docs/k6/latest/), an open source tool that runs test scripts written in JavaScript. ## Test your production environment development environments have much lower rate limits than production environments, and they run on shared development infrastructure. A load test against a development environment reaches its limits long before your production app would, so its numbers do not tell you how production behaves. Run load tests against your production environment. Load test traffic counts the same as real traffic. It consumes your production environment's [rate limits](https://docs.gadget.dev/guides/development-tools/rate-limits), it consumes platform credits for every request and action, and requests above the surge threshold are billed at the [surge compute](https://docs.gadget.dev/guides/account-and-billing#surge-compute) rate. Run your tests at a quiet time of day so the test and your real users are not competing for the same limits, and stop a test as soon as it starts returning `429` errors. Your production environment's limits depend on your plan. The [pricing](https://gadget.dev/pricing) page lists the included requests per 10-second window and the included database time for each plan, and each [execution capacity](https://docs.gadget.dev/guides/account-and-billing#execution-capacity) unit adds `+500` requests and `+20,000ms` of database time per 10 seconds. The included request count is the surge threshold, not the point of rejection. Above it, requests keep running on [surge compute](https://docs.gadget.dev/guides/account-and-billing#surge-compute), which is billed at a higher rate, until usage reaches the environment's hard limit, where requests are rejected with `429` errors. Read the numbers for your plan before you pick a target rate. For example, a production environment on the Pro plan includes 500 requests per 10 seconds. A test that sends 60 requests per second fills that bucket in under a minute. From then on, about 50 requests per second keep passing through the included bucket as it drains, and the other 10 per second spill over into surge compute and are billed at the surge rate. Rate limits exist to protect your app and the platform. You do not need to tell Gadget before running a load test. A test that reaches the hard limit is rejected with `429` errors in the same way as any other burst of traffic. ## Choose a tool Any HTTP load testing tool can test a Gadget app, because your app's Public API, Internal API, and HTTP routes are plain HTTPS endpoints. Pick a tool that can: * send a fixed number of requests per second, so a slow response does not quietly lower the load * stop the test on its own when an error rate or a latency percentile crosses a line you set * read a response body, so it can tell a rate limit rejection apart from a real failure * keep its scripts in your repository next to your app code These tools meet those needs: * **[Grafana k6](https://grafana.com/docs/k6/latest/)**: JavaScript scripts run by a single binary, with arrival rate executors, thresholds that can abort the test, and a hosted runner for tests larger than one machine can generate * **[Artillery](https://www.artillery.io/docs)**: YAML scenarios with JavaScript hooks and a hosted runner * **[Locust](https://docs.locust.io/)**: Python scripts with a web UI for steering the test while it runs * **[Gatling](https://docs.gatling.io/)**: Java, Kotlin, or Scala scripts with detailed HTML reports For a quick check of one endpoint, a command line tool like [oha](https://github.com/hatoo/oha) or [autocannon](https://github.com/mcollina/autocannon) is enough. The rest of this guide uses k6, but the same steps apply to any of these tools. ## Choose what to test Test the requests that real users make, in the same proportions. A test that only reads records tells you little about an app whose busiest moment is a checkout that runs three actions. Each kind of request exercises a different part of your app: * **Public API reads**, such as `findMany` queries, are served without a worker. They consume the request rate limit and the database rate limit, but not worker CPU time. * **Actions**, called through the Public API, run your code on a worker inside a database transaction. They consume several units of the request rate limit per call, plus database time and worker CPU time. * **HTTP routes** run your code on a worker. They consume one unit of the request rate limit per request, plus whatever the route handler does. * **Frontend assets** are served from Gadget's CDN and are [exempt from the request rate limit](https://docs.gadget.dev/guides/development-tools/rate-limits#request-rate-limit-exemptions). Leave them out of your test, because they add load to the CDN and not to your app. An action consumes more than one unit of the request rate limit per call. A `create` action that does nothing but save its record consumes 4 units: 1 to start the action, 2 to open and commit the transaction, and 1 for the `save` call. With 500 included requests per 10 seconds, that is 125 creates per 10 seconds, or 12 per second, before the environment starts surging and before any other traffic. See [request rate limit](https://docs.gadget.dev/guides/development-tools/rate-limits#request-rate-limit) for how each call is counted. ## Set up k6 Install k6 with a package manager, or run it from the Docker image: ```bash brew install k6 ``` Keep your test scripts in a `load-tests` folder at the root of your project. Create or update the `.ignore` file at the root of your project so the scripts are not synced to Gadget or bundled into your app: ```markdown // in .ignore load-tests/ ``` Add the entry before you create the folder. If `ggt dev` is running, files that exist before the `.ignore` entry is pushed are synced to your development environment. Create an API key for the test from the **Settings** > **API keys** page of the Gadget editor, and turn **Access control** off for it. The cleanup step in this guide deletes records through the Internal API, which only accepts a key with access control turned off. A key like this can read and write every record in the environment, so treat it as a secret and delete it when you finish testing. API keys belong to one environment. Create the key in your production environment, because that is the environment you are testing. You can split this into two keys: one with a role that grants only the permissions the requests under test need, and one with access control off that the script uses only in `teardown`. See [API key roles](https://docs.gadget.dev/guides/access-control#api-key-roles) for how roles apply to API keys. Pass the app URL and the API key into k6 as environment variables rather than writing them into the script: ```bash export APP_URL=https://your-app.gadget.app export GADGET_API_KEY=gsk-a1z1z1z1z1z1z1z1z11z ``` The scripts read them from `__ENV`, which k6 fills from the `--env` flag and from the shell environment. ## Write a smoke test A smoke test sends a handful of requests to prove that the script works and the app answers. Write and run it before any test that adds real load. The Public API is a GraphQL endpoint at `/api/graphql` that accepts an API key as a bearer token. This script reads the first 50 products, checks the response, and counts `429` responses on their own metric so a rate limit rejection is not mistaken for a broken app: ```javascript // in load-tests/smoke.js import http from "k6/http"; import { check } from "k6"; import { Rate } from "k6/metrics"; const APP_URL = __ENV.APP_URL; const API_KEY = __ENV.GADGET_API_KEY; // a 429 is a rate limit rejection, not a failed request http.setResponseCallback(http.expectedStatuses(200, 429)); const rateLimited = new Rate("rate_limited"); export const options = { thresholds: { // abort the run if more than 5% of requests are rate limited rate_limited: [{ threshold: "rate<0.05", abortOnFail: true, delayAbortEval: "10s" }], // anything other than a 200 or a 429 is a real failure http_req_failed: ["rate<0.01"], http_req_duration: ["p(95)<500"], }, }; const productsQuery = ` query LoadTestProducts { products(first: 50) { edges { node { id title price } } } } `; export default function () { const res = http.post(`${APP_URL}/api/graphql`, JSON.stringify({ query: productsQuery }), { headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}`, }, // group every request to this query under one name in the results tags: { name: "product.findMany" }, }); rateLimited.add(res.status === 429); check(res, { "status is 200": (r) => r.status === 200, "no GraphQL errors": (r) => r.status === 200 && !r.json("errors"), "returned products": (r) => r.status === 200 && r.json("data.products.edges").length > 0, }); } ``` Run it with a fixed number of iterations: ```bash k6 run --iterations 10 load-tests/smoke.js ``` k6 prints a summary when the run ends. The `checks` line shows how many assertions passed, `http_req_duration` shows the latency percentiles, and each threshold is marked as passed or failed. If any check fails, fix the script before adding load. A `403` response with the code `GGT_PERMISSION_DENIED` means the API key has access control on and its role is missing a permission the query needs. The `returned products` check needs at least one record in the model, so run it after the model has data. The first requests to an environment that has been quiet are slower than the rest while Gadget warms up the caches and workers that serve it, and one of them can time out. Expect the latency threshold to fail on the first smoke run and pass on the second. If the second run is still slow, the environment is not warming up, and the result is real. The `rate_limited` threshold is what makes the test safe to point at production. When more than 5% of requests are rejected, k6 stops sending traffic instead of holding your app at its limit for the rest of the run. The `delayAbortEval` option gives the first 10 seconds a chance to settle before the threshold is evaluated. ## Test actions and routes Actions are where your code runs, so they are the most useful thing to load test. They also need the most care. Every iteration creates real records in your production database, and a rate limit reached inside an action comes back as a `200` response with the error in the action's `errors` list rather than as a `429`. Give every record the test creates a value you can filter on, and delete them in `teardown` when the run ends. This script calls a `create` action on a `product` model and cleans up after itself: ```javascript // in load-tests/create-product.js import http from "k6/http"; import { check, sleep } from "k6"; import { Rate } from "k6/metrics"; import exec from "k6/execution"; const APP_URL = __ENV.APP_URL; const API_KEY = __ENV.GADGET_API_KEY; http.setResponseCallback(http.expectedStatuses(200, 429)); const rateLimited = new Rate("rate_limited"); export const options = { // cleanup may have to wait out the rate limit that stopped the test, so give it longer than the 60-second default teardownTimeout: "5m", thresholds: { rate_limited: [{ threshold: "rate<0.05", abortOnFail: true, delayAbortEval: "10s" }], // an action that fails validation or permissions still returns a 200, so abort on failed checks too checks: [{ threshold: "rate>0.99", abortOnFail: true, delayAbortEval: "10s" }], http_req_failed: ["rate<0.01"], // scope latency to the request under test, so the cleanup request does not skew it "http_req_duration{name:product.create}": ["p(95)<1000"], }, }; function graphql(query, variables, name) { return http.post(`${APP_URL}/api/graphql`, JSON.stringify({ query, variables }), { headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}`, }, tags: { name }, }); } // null for a non-200 response or a body that is not JSON, such as a gateway timeout function resultAt(res, path) { if (res.status !== 200) return null; try { return res.json(path); } catch (error) { return null; } } const createProductMutation = ` mutation LoadTestCreateProduct($product: CreateProductInput) { createProduct(product: $product) { success errors { code message } product { id } } } `; export function setup() { // one marker per run, shared with every virtual user and with teardown return { runMarker: `load-test-${__ENV.RUN_ID || Date.now()}` }; } export default function ({ runMarker }) { const res = graphql( createProductMutation, // idInTest is unique across load generators, where __VU is not { product: { title: runMarker, price: 10, sku: `${runMarker}-${exec.vu.idInTest}-${__ITER}` } }, "product.create" ); const result = resultAt(res, "data.createProduct"); // a limit reached inside the action arrives as a 200 with this code, and errors is null on success const rejectedInsideAction = result && (result.errors || []).some((error) => error.code === "GGT_TOO_MANY_REQUESTS"); rateLimited.add(res.status === 429 || rejectedInsideAction); check(res, { "product created": () => result && result.success === true, }); } const deleteManyMutation = ` mutation LoadTestCleanupProducts($title: String!) { internal { deleteManyProduct(filter: { title: { equals: $title } }) { success errors { message } } } } `; export function teardown({ runMarker }) { // the Internal API deletes by filter without running the delete action, so cleanup is one request for (let attempt = 0; attempt < 5; attempt++) { const res = graphql(deleteManyMutation, { title: runMarker }, "product.cleanup"); if (resultAt(res, "data.internal.deleteManyProduct.success")) return; // cleanup runs right after the load, so wait out a rate limit rather than leave records behind sleep(5); } throw new Error(`cleanup did not finish for ${runMarker}`); } ``` The `setup` function runs once before the first iteration and its return value is passed to every iteration and to `teardown`. Code outside these functions runs again for each virtual user and again for `teardown`, so a marker built there would take a different value in each place. The `teardown` function runs once after the last iteration, and the cleanup request is tagged `product.cleanup` so it shows up separately from the requests under test in the summary. Cleanup goes through the Internal API because it deletes every record that matches a filter in one request and runs no action code. The [Internal API](https://docs.gadget.dev/guides/data-access/api#public-api-vs-internal-api) skips access control, which is why the API key for the test has access control turned off. If your `delete` action has code that must run for each record, call the `bulkDeleteProducts` mutation on the Public API instead. That runs the `delete` action once per record, so deleting what the test created costs about as much as creating it, and it happens all at once. For example, a 7-minute run that created 2,700 products at up to 10 per second and deleted them with bulk calls at the end ran 2,700 `delete` actions inside one minute, and used more database time in that minute than the creates did in any minute of the run. Expect that spike at the end of the run on the operations dashboard, and read the charts for the test window without it. Only load test actions whose side effects you can undo. An action that sends an email, charges a card, or calls a third-party API does those things for every iteration. For actions like these, point the integration at its sandbox for the duration of the test, or skip the side effect only when the action can verify that the caller is the test, for example by comparing a parameter against a secret stored in an environment variable. Do not add a parameter that any caller can pass, because that opens the same bypass to every user of your production API. HTTP routes need no GraphQL wrapper. Use the `name` tag so requests with different path segments are grouped under one name: ```javascript // in load-tests/inventory-route.js import http from "k6/http"; import { check } from "k6"; const APP_URL = __ENV.APP_URL; const skuCodes = ["SKU-100", "SKU-200", "SKU-300"]; export default function () { const sku = skuCodes[__ITER % skuCodes.length]; const res = http.get(`${APP_URL}/inventory/${sku}`, { // without this tag, k6 reports each sku as a separate URL tags: { name: "GET /inventory/:sku" }, }); check(res, { "status is 200": (r) => r.status === 200, }); } ``` This calls a route defined in `api/routes/inventory/GET-[sku].ts`. See [route structure](https://docs.gadget.dev/guides/http-routes/route-structure#dynamic-segments) for how path segments map to route files. The route script and the page script that follows leave out the `rate_limited` metric and the thresholds to stay short, while the signed-in script later in this guide includes them. A failed check on its own does not stop a run. Copy the response callback, the metric, and the thresholds from the smoke test into any script before you give it a scenario that adds real load. ## Test web pages In a React Router app in framework mode with server rendering on, which is how Gadget sets up new apps, a page is rendered on a worker when a request arrives, and the route's `loader` functions run there too. A page request is an HTTP route request plus whatever its loaders do, and it consumes the request rate limit like any other route. The JavaScript, CSS, and images the page references are served from the CDN and are exempt, so a test that fetches only the page HTML measures your server rendering and loaders without the assets. If your app sets `ssr: false` in `react-router.config.ts`, or uses declarative mode, the page HTML is a static shell and the page's work happens in the API requests the browser makes after it loads. Fetching the HTML then measures very little. Test the actions and queries the page calls instead, as a signed-in user where the page needs one. Request the page as a browser would, with redirects turned off, and check for something the server render must produce. A page behind sign-in answers with a redirect, which then shows up as a failed status check rather than as a successful load of the sign-in page: ```javascript // in load-tests/pages.js import http from "k6/http"; import { check } from "k6"; const APP_URL = __ENV.APP_URL; export default function () { const res = http.get(`${APP_URL}/`, { headers: { Accept: "text/html" }, // a protected page redirects to sign-in, and following it would measure the wrong page redirects: 0, tags: { name: "GET /" }, }); check(res, { "status is 200": (r) => r.status === 200, "rendered the page": (r) => r.body.includes("