PRACTICAL GUIDE / Playwright APIResponse dispose memory
Stop large API responses from quietly filling worker memory
Release Playwright API response bodies at the right time, diagnose worker memory growth, and keep the evidence your assertions actually need.
In this guide6 sections
- Understand which bytes disposal can actually release
- Use three cleanup patterns for three different jobs
- Measure a staircase before calling it a response leak
- Separate response retention from failures with the same graph
- Prove whether the worker or its process tree owns the rise
- Migrate a large suite without deleting diagnostic value
- Keep the body when early disposal would weaken the test
What you will learn
- Understand which bytes disposal can actually release
- Use three cleanup patterns for three different jobs
- Measure a staircase before calling it a response leak
- Separate response retention from failures with the same graph
An API-only shard starts near 350 MB and finishes above 2 GB, even though most checks read only status codes and headers. The failures appear late, often as a killed worker or an unrelated timeout. Re-running a single test stays green because the memory pressure comes from hundreds of completed responses sharing one long-lived request context.
This is not a reason to call garbage collection after every test. It is a resource-ownership problem. Playwright tells you exactly when it retains response bodies and gives you two cleanup levels; the useful choice depends on whether the caller still needs the body or the whole request context.
Understand which bytes disposal can actually release
APIRequestContext methods return an APIResponse after the HTTP exchange completes. Playwright stores responses in memory so a test can ask for the body later. That is why await response.body() still works several steps after request.get() resolved. The convenience has a cost: a body that no assertion reads can remain retained until the response or its context is disposed.
await response.dispose() releases the body associated with that one APIResponse. It does not close the request context, clear its cookies, interrupt other calls, or cancel a request that is already complete. Status, headers, URL, and other small metadata are not the main reason to use it; the stored body is. Treat the call as the point after which response-body access is no longer part of the test's contract.
await apiRequestContext.dispose() is broader. It discards all resources held by that context, including its retained responses, and makes the context unusable for later requests. Context disposal is ideal when the context belongs to one test or one bounded fixture. Per-response disposal matters when the same context serves a long batch, a worker-scoped fixture, or a suite-level setup that cannot close yet.
There is a second owner that Playwright cannot clean for you. Calling await response.body() returns a Node.js Buffer. Calling await response.json() creates a JavaScript value. If the test stores either result in an array, closure, cache, test attachment, or module-level variable, response.dispose() does not invalidate that copy. Playwright releases its retained body, while your reference remains live.
That distinction explains a common disappointing measurement. A test reads a 100 MB archive into const bytes, disposes the response, and checks process memory while bytes is still in scope. The large buffer is still reachable. Even after the reference becomes unreachable, the JavaScript runtime or native allocator may keep memory reserved for reuse, so resident set size does not have to drop immediately. Disposal prevents unbounded retention; it does not promise an instant downward line in an operating-system graph.
Parsing can increase the peak. A 40 MB JSON response may exist first as encoded bytes, then as a decoded string, then as a large object graph. Exact representations depend on the runtime and parser, but the practical point is stable: reading and parsing a body can require more memory than its content-length. Disposing the Playwright response after parsing removes one owner, not the parsed graph the assertions are traversing.
APIResponse is also different from the browser-side Response returned by page network events and from Playwright's Download object. Do not add dispose() calls to unrelated response types because the name sounds plausible. The method discussed here belongs to responses produced by APIRequestContext methods such as get(), post(), and fetch().
Use three cleanup patterns for three different jobs
The cleanest example is a large endpoint where the contract is entirely in metadata. A synthetic export service below returns an 8 MiB body, but the test only needs to prove that the endpoint is available, returns the binary media type, and declares the expected size. The complete file runs with Playwright Test and has no external dependency.
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { test, expect } from '@playwright/test';
const exportBytes = Buffer.alloc(8 * 1024 * 1024, 0x61);
let server: Server;
let origin: string;
test.beforeAll(async () => {
server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/exports/nightly.bin') {
res.writeHead(200, {
'content-type': 'application/octet-stream',
'content-length': String(exportBytes.byteLength),
'x-export-version': '42',
});
res.end(exportBytes);
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ code: 'NOT_FOUND' }));
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address() as AddressInfo;
origin = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
});
});
test('the nightly export advertises the expected artifact', async ({ request }) => {
const response = await request.get(`${origin}/exports/nightly.bin`);
try {
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toBe('application/octet-stream');
expect(response.headers()['content-length']).toBe(String(8 * 1024 * 1024));
expect(response.headers()['x-export-version']).toBe('42');
} finally {
await response.dispose();
}
});The finally block is important. An assertion can fail before the last line in the happy path. Cleanup placed after all assertions would be skipped in exactly the runs where retaining many failed bodies is least helpful. finally preserves the original assertion failure unless disposal itself fails, and it makes ownership visible to a reviewer.
Do not replace a required content assertion with a header just to save memory. content-length proves what the server declared, not that the bytes are correct. The next pattern reads the body because the artifact itself is under test. Disposal is guaranteed immediately after validation finishes, including when an assertion or digest operation fails. Add this test below the fixture from the previous example:
import { createHash } from 'node:crypto';
test('the downloaded bytes match the expected digest', async ({ request }) => {
const expectedDigest = createHash('sha256').update(exportBytes).digest('hex');
const response = await request.get(`${origin}/exports/nightly.bin`);
try {
expect(response.status()).toBe(200);
const receivedBytes = await response.body();
const actualDigest = createHash('sha256')
.update(receivedBytes)
.digest('hex');
expect(receivedBytes.byteLength).toBe(exportBytes.byteLength);
expect(actualDigest).toBe(expectedDigest);
} finally {
await response.dispose();
}
});Peak memory is higher in this test because receivedBytes must remain available for hashing. Early response disposal still helps after validation: Playwright no longer needs to retain its body for the rest of the test, and finally runs even when hashing or an assertion fails. The local buffer leaves scope with the try block and can be reclaimed after its last reference is released. For a production artifact hundreds of megabytes in size, APIResponse.body() is the wrong interface if the requirement can be checked with a streaming client. APIResponse exposes a complete body buffer, not a readable stream.
The third pattern addresses batches. Promise.all() over two hundred large endpoints can make every response live at once. Sequential processing caps that concurrency at one, extracts a tiny record, and disposes before the next request. Add an endpoint and test like these to the local server example:
// Add this branch inside the createServer callback, before the 404 branch.
if (req.method === 'GET' && req.url?.startsWith('/snapshots/')) {
const snapshotId = req.url.slice('/snapshots/'.length);
const body = Buffer.alloc(2 * 1024 * 1024, snapshotId);
res.writeHead(200, {
'content-type': 'application/octet-stream',
'content-length': String(body.byteLength),
'x-snapshot-id': snapshotId,
});
res.end(body);
return;
}
test('snapshot inventory keeps only bounded metadata', async ({ request }) => {
const records: Array<{ id: string; size: number }> = [];
for (let index = 0; index < 40; index += 1) {
const id = `snapshot-${index}`;
const response = await request.get(`${origin}/snapshots/${id}`);
try {
expect(response.status(), `status for ${id}`).toBe(200);
expect(response.headers()['x-snapshot-id']).toBe(id);
records.push({
id,
size: Number(response.headers()['content-length']),
});
} finally {
await response.dispose();
}
}
expect(records).toHaveLength(40);
expect(records.every(({ size }) => size === 2 * 1024 * 1024)).toBe(true);
});Sequential execution costs latency. If one request takes 200 ms, forty requests add roughly eight seconds before server processing and transfer time. Bounded concurrency is a useful compromise, but its implementation must still dispose every settled response, including failures. Start sequentially while proving the memory diagnosis, then raise concurrency to a measured limit rather than returning to an unbounded Promise.all().
Chunking is a simple limiter when all requests have similar cost. This replacement for the sequential loop keeps at most four 2 MiB responses owned by the batch at one time. It fits the same local fixture and keeps disposal inside the task that obtained the response.
test('snapshot inventory uses measured four-request batches', async ({ request }) => {
const ids = Array.from({ length: 40 }, (_, index) => `snapshot-${index}`);
const records: Array<{ id: string; size: number }> = [];
const concurrency = 4;
for (let start = 0; start < ids.length; start += concurrency) {
const batch = ids.slice(start, start + concurrency);
const outcomes = await Promise.allSettled(batch.map(async id => {
const response = await request.get(`${origin}/snapshots/${id}`);
try {
expect(response.status(), `status for ${id}`).toBe(200);
return {
id,
size: Number(response.headers()['content-length']),
};
} finally {
await response.dispose();
}
}));
for (const outcome of outcomes) {
if (outcome.status === 'rejected') throw outcome.reason;
records.push(outcome.value);
}
}
expect(records).toHaveLength(40);
});Promise.allSettled() waits for every started callback to finish its disposal before the first recorded rejection is thrown. That is safer than letting fixture teardown begin while siblings from a rejected Promise.all() are still using the request context. Four is an example, not a universal optimum. The upper bound now includes four retained Playwright bodies, response metadata, socket buffers, and any copies created by assertions. Increase the value only after comparing elapsed time and peak memory under the same CI limit. Chunking also waits for the slowest call in each group before starting the next group; a queue-based limiter can use capacity more efficiently, but it adds code and failure-handling paths that need their own tests.
The tiny records array is intentional. Keeping forty URLs, IDs, numeric sizes, and statuses is cheap and useful. Keeping forty full buffers is not. Extract the minimum stable evidence needed for the final assertion and release the heavy owner inside the loop.
Measure a staircase before calling it a response leak
Out-of-memory failures are late evidence. Add bounded measurement around the suspected loop before changing code. Node's process.memoryUsage() exposes resident set size, heap usage, external memory, and array-buffer memory. Log every tenth or fiftieth iteration, not every request, so diagnostics do not overwhelm the report.
function memoryLine(label: string): string {
const usage = process.memoryUsage();
const mib = (bytes: number) => Math.round(bytes / 1024 / 1024);
return [
label,
`rss=${mib(usage.rss)}MiB`,
`heap=${mib(usage.heapUsed)}MiB`,
`external=${mib(usage.external)}MiB`,
`arrayBuffers=${mib(usage.arrayBuffers)}MiB`,
].join(' ');
}
console.log(memoryLine('before batch'));
// Run a measured portion of the batch here.
console.log(memoryLine('after batch'));A response-retention signature is a repeatable staircase tied to completed API calls. With the same payload sizes and worker count, memory grows as the number of undisposed responses grows. Closing the owning context at the end stops growth across later groups. Adding per-response disposal changes the slope substantially even though short-term RSS may not fall.
An illustrative retention profile has the shape you are looking for; these figures are examples, not measurements from a run of this experiment:
before batch rss=318MiB heap=74MiB external=19MiB arrayBuffers=7MiB
after 50 rss=836MiB heap=82MiB external=531MiB arrayBuffers=507MiB
after 100 rss=1349MiB heap=89MiB external=1043MiB arrayBuffers=1019MiBDo not copy those numbers into a threshold. Payload size, Node version, operating system, reporters, trace settings, and Playwright version all change the baseline. The evidence is the relationship between requests and retained bytes in your controlled run. Compare before and after using the same test subset and configuration.
Pair each sample with a completed-response count and the total declared bytes received. Time alone is a weak x-axis because a slow environment can spend minutes without creating new bodies. If memory rises by roughly one payload size after each completed call and stops rising when disposal is introduced, the causal case is strong. If it grows while the request count is flat, inspect a timer, reporter queue, browser page, or background service instead.
Also measure the forced-failure path. Make a harmless assertion fail after the first few responses and confirm cleanup still flattens the next run. Suites often look healthy when every assertion passes but retain large bodies during an outage, when dozens of error responses and attachments accumulate together. A memory policy that works only on green runs will fail at the worst operational moment.
Run one shard with a single worker to make the process easier to follow:
npx playwright test tests/api/exports.spec.ts --workers=1 --reporter=lineThen repeat at the normal worker count. A single worker is diagnostic isolation, not a fix. If eight workers each retain 500 MB, reducing to one only moves the capacity limit and lengthens the suite. The ownership defect remains.
For runtime allocation evidence, launch the Playwright CLI through Node with garbage-collection tracing:
node --trace-gc ./node_modules/@playwright/test/cli.js test \
tests/api/exports.spec.ts --workers=1 --reporter=lineLines containing repeated mark-sweep activity near the heap limit tell you the runtime is under pressure. Large response buffers can be reflected more strongly in external and arrayBuffers than in heapUsed, so a modest heap graph does not clear them. Watch the operating-system process tree as well when the test starts browsers or other services; the largest process may not be the Playwright worker you instrumented.
Take measurements with traces, videos, screenshots, and verbose attachments set exactly as CI uses them. Turning everything off may remove the real source. Conversely, if response cleanup changes nothing, that is valuable evidence to investigate a different owner instead of adding disposal calls blindly.
Separate response retention from failures with the same graph
Test attachments can reproduce the same steady climb. A helper reads each response body, attaches it to testInfo, then disposes the response. Playwright has released its copy, but the reporter still needs the attachment for the result. If the body is useful only on failure, attach a redacted and size-limited sample after an assertion fails. Do not attach multi-megabyte success payloads to every result.
Tracing is another near-miss. Network resources, DOM snapshots, screenshots, and source data have their own storage and memory costs. Compare a run with the normal trace policy against one narrowly altered diagnostic run. If memory growth tracks page actions rather than APIRequestContext calls, response disposal is unlikely to be the main lever. Restore the standard trace policy after the experiment because a trace is often the only useful artifact from a CI-only failure.
Application memory can dominate an end-to-end worker. A page that leaks detached DOM nodes or keeps decoded images alive will grow while an API cleanup helper happens to run nearby. Split the suspected API loop into an API-only spec with no browser page. If its slope is flat but the full scenario climbs, inspect the browser process and page behavior.
Parsed data caches are especially deceptive. Consider this helper:
const seenPayloads: unknown[] = [];
async function rememberResponse(response: import('@playwright/test').APIResponse) {
seenPayloads.push(await response.json());
await response.dispose();
}Every Playwright body is disposed, yet every parsed object remains reachable from a module-level array for the life of the worker. Replace the cache with bounded metadata, clear it at the owner boundary, or remove it. A garbage collector cannot reclaim reachable objects.
Failure paths create a similar leak when disposal sits only on the happy path. Search for a request followed by several assertions and a final cleanup line. Force the first assertion to fail locally and confirm finally still executes. This review catches more than memory problems; it also exposes contexts, servers, and created test data that survive failed cases.
Unclosed request contexts form the broadest version. A suite creates a new context for each tenant or credential set, stores them in a map, and never calls dispose(). Per-response disposal reduces body retention but leaves cookies, connection resources, and the usable contexts alive. Put context creation and disposal in the same fixture or try/finally boundary. Use response cleanup inside that boundary only when its lifetime is still too long.
Prove whether the worker or its process tree owns the rise
A CI memory chart can make two failures look identical. The container working set climbs during an API batch and the worker is eventually killed. One cause is undisposed response bodies in the Node worker. A different cause is growth in another process launched by the job, such as a browser, a local service, or a reporting process. The container graph adds them together, so its smooth upward line cannot identify the owner.
Put process identity beside every sample. The useful worker record contains its process ID, role, completed-response count, rss, heapUsed, external, and arrayBuffers. For response retention, the broken comparison normally has external or arrayBuffers rising in the same worker as completed large responses accumulate. After disposal is added, those fields should stop gaining roughly in step with each batch, even if rss remains reserved. A healthy bounded run therefore means a flattened per-worker slope, not an immediate return to the starting resident set.
Now compare that record with the job-level chart. If the container keeps climbing while the identified worker's fields remain in a stable band, response disposal in that worker is the wrong fix. Attribute the remaining usage to actual process identities before changing tests. A misleading value is a single heapUsed number. Large response bytes can sit outside the JavaScript heap, while a browser or child service will not appear in the worker's process.memoryUsage() at all. Another misleading pattern is a low final value from a replacement worker. If the original process was killed and the runner started a new one, the last sample belongs to a different lifetime.
Keep the test identity, shard, worker index, process ID, and sample sequence together. A response-retention case should reproduce in the same process over several batches. A process-tree case will show the worker line staying bounded while another process or the aggregate diverges. If the platform exposes only a container total, the test team can still provide the negative evidence that its own worker stayed bounded. The platform team then has a precise reason to collect per-process data rather than asking for more dispose() calls.
Ownership follows the first divergent metric. The test automation owner fixes response and request-context lifetimes when worker external memory tracks completed calls. The reporter owner handles retained attachments when result production owns the bytes. The browser or application team owns growth in its process. The CI platform owner handles container limits and kill records, but should not be asked to raise the limit until the process owner is known.
A useful handoff contains the exact shard command, payload class, declared response sizes where available, concurrency, completed-response count at each sample, worker process ID, the last samples before termination, and whether cleanup ran on the forced-failure path. Include the container limit and termination reason from the platform record. Do not send only a screenshot of the aggregate line, because it omits both process ownership and request progress.
Per-process sampling has a maintenance cost. The suite must preserve identities across retries and worker replacement, and frequent logs can bury the failing assertion. Sample at fixed batch boundaries and retain the detailed record only for the diagnostic shard. This method also does not detect a short peak that occurs entirely between samples. A single large body can exceed the limit before the next line is printed, so peak-size tests still need isolation or a streaming design.
Migrate a large suite without deleting diagnostic value
Start with measurement and ownership, not a mechanical search-and-replace. List request contexts and classify them as test-scoped, worker-scoped, or manually created. Test-scoped contexts with small responses may already be disposed promptly by their fixture. Worker-scoped contexts and loops that fetch exports, reports, media, or search result sets deserve attention first.
At each high-volume call site, record what later code reads from the response. Status and headers are synchronous metadata calls. Body, text, and JSON reads are asynchronous and require the retained body. Move disposal immediately after the final required read, inside finally when assertions can interrupt the path. Do not dispose directly after get() and hope a later helper can still call json().
Change one endpoint family at a time. Capture the same batch-size memory lines before and after, using the same worker count and reporter. Also compare failure artifacts. An optimization that lowers memory but removes the error body needed to diagnose a broken contract is not finished. Preserve a bounded, redacted excerpt or stable error code before cleanup.
Add a small helper only if it makes ownership harder to forget. A callback shape can keep response access inside a bounded scope:
import type { APIResponse } from '@playwright/test';
async function usingResponse<T>(
responsePromise: Promise<APIResponse>,
inspect: (response: APIResponse) => Promise<T>,
): Promise<T> {
const response = await responsePromise;
try {
return await inspect(response);
} finally {
await response.dispose();
}
}The helper costs an extra abstraction and can make stack traces one frame longer. Keep the callback narrow and never return the APIResponse itself, because that would hand callers an already-disposed object. It is suitable for repeated metadata checks or immediate parsing. A direct try/finally remains clearer at unusual call sites.
After per-response cleanup, shorten context lifetimes where practical. Moving a worker-scoped context to test scope improves isolation and guarantees a broad cleanup boundary, but it adds context creation overhead and may repeat authentication. Measure that cost. A tenant API login that takes two seconds across a thousand tests may justify a worker scope plus disciplined response disposal; a cheap local context usually does not.
Put a regression batch in the suite with realistic payload sizes, not a fragile exact-RSS assertion. Memory metrics vary too much for a fixed threshold on shared CI. A better guard asserts that the batch completes under the worker's actual container limit and emits sampled measurements for trend analysis. Infrastructure monitoring can alert on a sustained percentile change across builds.
Review the rollout for parallelism. Code that was safe sequentially may retain several in-flight bodies after someone adds concurrency. State the allowed batch size and concurrency next to the loop. If higher throughput matters, use a tested limiter and dispose each response in the task that owns it.
Keep the body when early disposal would weaken the test
Content contracts require content. A checksum test, schema validation, image decode, archive inspection, or error-document assertion must read the body before disposal. Replacing these checks with status and content-length reduces coverage. Accept the temporary memory cost, isolate the test, and avoid running many equivalent large-body checks concurrently.
Failure diagnosis can also justify retention until an assertion finishes. If a 500 body contains a stable service error code, extract a safe bounded value before disposal and attach it only when the test fails. Never keep an entire response merely because it might be useful. Decide the maximum diagnostic bytes and redact secrets at the boundary.
Tiny responses in short-lived contexts do not need ceremony at every line. A test-scoped context that makes three small JSON calls and is always disposed can release everything at once. Adding five nested try/finally blocks may make the contract harder to read for negligible savings. The trade-off is delayed release until the test ends; verify that payload size and test count keep that delay harmless.
Early disposal is also the wrong answer when another helper legitimately owns the next read. Move ownership rather than racing two consumers. Either let the helper receive and dispose the response, or extract an immutable value and pass that value onward. Shared mutable ownership of an APIResponse invites one function to dispose while another still expects text() to work.
Finally, disposal cannot turn a buffered API into a streaming one. If a test must validate a multi-gigabyte artifact, body() creates a complete buffer and sets the peak before cleanup can help. Use an interface designed for streaming, validate the artifact in chunks, or test a smaller representative object when the product risk allows it. That choice costs implementation complexity or some end-to-end coverage, but it addresses the real peak instead of cleaning up only after the peak has already happened.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Why does Playwright keep API response bodies in memory?
Playwright retains responses returned by an `APIRequestContext` so the test can call `body()`, `text()`, or `json()` later. The retained body remains available until that response or its owning request context is disposed.
Can I call APIResponse.dispose after reading JSON?
Yes. Finish every assertion or extraction that needs the `APIResponse`, then await `dispose()`. Any JavaScript object produced by `json()` remains owned by your test and is not freed by disposing the Playwright response.
Does disposing an APIResponse cancel the HTTP request?
No request is cancelled by that cleanup because `APIResponse` exists only after Playwright has received the response. Disposal releases the stored response body; it is not an abort or streaming API.
Should I dispose every response in a Playwright API test?
Long-lived contexts and loops over large payloads benefit most from per-response cleanup. Short tests with small bodies can rely on context disposal, provided the context has a clear and prompt lifetime.
Why is memory still high after response.dispose()?
A copied `Buffer`, parsed JSON object, trace, attachment, or application allocation may still be reachable after the Playwright body is released. Runtime allocators can also keep freed memory reserved instead of returning RSS to the operating system immediately.
RELATED GUIDES
Continue the learning route
GUIDE 01
Dispose APIRequestContext Correctly in Large Playwright Suites
Master Playwright APIRequestContext dispose with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 02
Test localStorage Directly with the Playwright API
Learn Playwright localStorage API testing through practical setup, failure analysis, CI evidence, security boundaries, and measurable release gates for QA and SDET teams.
GUIDE 03
Test sessionStorage Directly with the Playwright API
A practical guide to Playwright sessionStorage API testing, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 04
Debug Memory Growth in Playwright Worker Processes
Master debug Playwright worker memory leak with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 05
Assert API TLS Security Details with Playwright
Learn Playwright API response TLS security details with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.