Or press ESC to close.

Catching Duplicate API Calls in UI Tests

Sep 7th 2025 12 min read
medium
typescript5.7.2
playwright1.55.0
web
ui
api

Modern web applications often fire off dozens of API requests behind the scenes, and when those calls get duplicated unintentionally they can slow down performance, overload backend services, or even cause flaky test results. While tools like Playwright make it easy to automate UI flows, detecting and preventing duplicate API calls is not built in. In this post, we will walk through a practical approach to catching duplicate API calls in Playwright tests, covering both real network requests and mocked endpoints, so your automation suite stays fast, reliable, and trustworthy.

The Test Application Setup

To demonstrate duplicate call detection we built a small demo web app with three tabs. Each tab contains buttons that trigger API calls and display the results in a content area. The first tab makes unique calls without duplicates, the second introduces one repeated request, and the third contains several intentional duplicates.

A section of the testing application

A section of the testing application

The application uses a mix of real and mocked APIs. When the Load Users or Load Posts buttons are clicked, requests go to the JSONPlaceholder service (/users and /posts). Other buttons such as Load Profile or Load Analytics return mock data directly from the page script. This combination makes it possible to test both real network traffic and simulated API calls.

Tracking API Calls in Playwright

The first step in detecting duplicates is to capture the requests our application makes. Playwright provides a simple way to do this with the page.on("request") event. Each time the page sends a network request, this event is triggered and gives access to details such as the request URL and HTTP method. By listening for these events, we can record every call and later check if any were repeated.

                
page.on("request", (request) => {
  const url = request.url();
  const method = request.method();
  console.log(`Request made: ${method} ${url}`);
});
                

This approach works well for requests that actually leave the browser and hit a real backend. For example, when the demo app loads users or posts, the calls go to https://jsonplaceholder.typicode.com and appear in the log. However, there is a limitation. Some of the endpoints in the demo, like /profile or /analytics, return data generated inside the page itself. These mocked calls never reach the network, which means page.on("request") will not see them. This creates a gap in detection that we will address in the next section.

Extending Tracking for Mocked Endpoints

Since some of the demo application's endpoints return mock data directly from the page script, they never trigger a real network request. To make sure these calls are also tracked, we added a small change to the function that simulates API calls. Inside makeApiCall, a console.log statement was placed at the start of the function. This provides a consistent marker each time a mocked endpoint is triggered.

                
async function makeApiCall(endpoint, tabId) {
  console.log(`[API_CALL] ${endpoint}`);
  // existing logic for fetching data or returning mock results
}
                

On the Playwright side, we capture these messages by listening to the page console. The page.on("console") event gives access to any output generated by the browser. By checking for log entries that start with [API_CALL], we can record mocked calls in the same way as real ones.

                
page.on("console", (msg) => {
  const text = msg.text();
  if (text.startsWith("[API_CALL]")) {
    const endpoint = text.replace("[API_CALL] ", "");
    console.log(`Mock call made: GET ${endpoint}`);
  }
});
                

With this setup, Playwright now observes both categories of requests. Real network traffic is captured through page.on("request"), while mock endpoints are caught through page.on("console"). Together, they provide full visibility into the application's API activity, making it possible to detect duplicates in every scenario.

Detecting Duplicates

Once all API calls are captured, the next step is to keep count of how many times each one appears. The logic is centralized in a helper method called handleApiCall. Each call is stored in a map with a unique key made of the HTTP method and the request URL. If the call is seen again, the counter increases. When the counter reaches two, the call is marked as a duplicate, and any further increments simply update the duplicate count.

                
private handleApiCall(key: string, url: string, method: string): void {
  if (this.apiCalls.has(key)) {
    const existingCall = this.apiCalls.get(key)!;
    existingCall.count++;

    if (existingCall.count === 2) {
      this.addDuplicate(existingCall.testName, url, method, existingCall.count);
    } else if (existingCall.count > 2) {
      this.updateDuplicateCount(existingCall.testName, url, method, existingCall.count);
    }
  } else {
    this.apiCalls.set(key, {
      url,
      method,
      testName: this.currentTestName,
      count: 1,
    });
  }
}
                

With this approach, duplicates are detected the moment a request is made more than once. A second occurrence is enough to flag it as a problem, and the count continues to update if the same call repeats a third or fourth time.

In practice, this works as expected across the three demo tabs. When the first tab loads users, posts, and settings, each endpoint is unique and no duplicates are reported. The second tab makes two requests to /profile, which triggers one duplicate warning. The third tab contains heavier repetition: /analytics is called three times and /reports is called twice, both of which appear in the duplicate report.

Filtering Out Noise

When duplicate detection was first applied, the results included false positives. The initial page load request for index.html appeared multiple times across the test run, and static assets such as stylesheets and scripts were also counted as repeated calls. These are not meaningful API requests, yet they caused the tracker to report duplicates where none actually existed.

To solve this, a filtering step was added before processing each request. The idea is simple: ignore anything that is clearly a page resource rather than an API call. By checking the file extension or URL, static files like .html, .css, and .js can be excluded.

                
page.on("request", (request) => {
  const url = request.url();

  // Ignore static assets and page loads
  if (url.endsWith(".html") || url.endsWith(".css") || url.endsWith(".js")) return;

  // Continue tracking only API-like endpoints
  this.handleApiCall(`${request.method()}:${url}`, url, request.method());
});
                

With this small adjustment, the tracker no longer confuses page resources for API traffic. The output becomes focused only on the endpoints that matter, making it clear when a real duplicate occurs in the application.

Putting It All Together

With filtering, counting, and console tracking in place, the final startTracking method combines everything into one clear implementation. It listens to both network requests and console logs, applies filters to skip irrelevant resources, and passes each valid call through the duplicate detection logic.

                
async startTracking(page: Page, testName: string): Promise {
  this.currentTestName = testName;
  this.isTracking = true;

  // Capture real network requests
  page.on("request", (request) => {
    if (!this.isTracking) return;

    const url = request.url();
    const method = request.method();

    // Ignore static assets
    if (url.endsWith(".html") || url.endsWith(".css") || url.endsWith(".js")) return;

    const trackedEndpoints = [
      "/users", "/posts", "/settings", "/profile",
      "/notifications", "/analytics", "/reports",
    ];
    if (!trackedEndpoints.some((endpoint) => url.includes(endpoint))) return;

    this.handleApiCall(`${method}:${url}`, url, method);
  });

  // Capture mocked calls logged in the browser
  page.on("console", (msg) => {
    if (!this.isTracking) return;

    const text = msg.text();
    if (text.startsWith("[API_CALL]")) {
      const endpoint = text.replace("[API_CALL] ", "");
      const method = "GET";
      this.handleApiCall(`${method}:${endpoint}`, endpoint, method);
    }
  });
}
                

Running the demo with this setup produces clear results. In the first tab, the tracker reports no duplicates. In the second tab, /profile is listed with two calls. In the third tab, /analytics is flagged with three calls and /reports with two. The log makes the duplicates easy to see at a glance:

Error: Duplicate API calls detected:

🚨 Duplicate API calls detected:

❌ Test: "Tab 2 - One duplicate API call (should be detected)"
   • GET /profile (called 2 times)

❌ Test: "Tab 3 - Multiple duplicate API calls (should detect multiple)"
   • GET /analytics (called 3 times)
   • GET /reports (called 2 times)

This confirms that the solution correctly separates unique requests from duplicate ones, and does so consistently across both real and mocked endpoints.

Conclusion

Detecting duplicate API calls in automated tests is a valuable way to catch hidden inefficiencies and prevent flaky results. By combining Playwright's page.on("request") for real network traffic with a lightweight console.log strategy for mocked endpoints, we built a tracker that works across different kinds of calls. Adding filters for static assets ensured that only meaningful API requests are considered, and counting logic made it easy to highlight when a request was made more than once.

The final result is a practical solution that provides clear feedback during test runs. It shows when and where duplicates occur, whether in simple flows or more complex scenarios, helping teams maintain both performance and reliability in their applications.

The complete code examples can be found on our GitHub page. See you in the next post!