The Great Page Leak Crisis: When Isolation Becomes Isolation

How testing both sides of a conversation led to discovering Playwright page management nightmare on CI and building a solution that keeps tests clean.

Harry Tran
10 min read · 1901 words

Have you ever tried to test a conversation? Not the kind you have over coffee, but the digital kind—when User A sends a message to User B, and you need to verify that both the sender sees "Message sent ✓" and the recipient gets the actual message with the right notification badge.

This was my Tuesday morning reality: staring at a test suite that needed to verify real-time messaging across different user perspectives. Simple concept, complex implementation.

#The Two-Window Problem

Picture this scenario: You're testing a messaging application where:

  • Sender side: User clicks "Send," message shows as delivered, chat history updates, typing indicator disappears
  • Recipient side: New message appears, notification badge increments, message timestamp shows correctly, read status tracks properly

The UI for each side is completely different. The sender sees their own chat interface with delivery confirmations, while the recipient sees an incoming message interface with different styling, notifications, and interaction patterns.

As any good QA engineer knows, you can't just test one side and hope the other works. You need both perspectives.

typescript
test('user can send and receive messages', async ({ browser }) => {
// Sender's perspective
const senderPage = await browser.newPage()
await senderPage.goto('/chat/sender-view')
// ... test sender logic
// Recipient's perspective
const recipientPage = await browser.newPage()
await recipientPage.goto('/chat/recipient-view')
// ... test recipient logic
})

Looks reasonable, right? Our tests ran beautifully on local development. Green checkmarks everywhere. We shipped with confidence.

Then came Monday morning.

#The CI Catastrophe

Our Jenkins builds started failing. Not the good kind of failing where a test catches a bug—the bad kind where the CI server itself starts choking.

ERROR: Test runner crashed after 45 minutes
CAUSE: Out of memory
STATUS: 127 processes still running
BROWSER WINDOWS: 847 open tabs

Eight hundred and forty-seven browser tabs. Our CI server was hosting the world's most expensive browser session.

Here's what was happening: In Playwright's excellent isolation model, each test gets its own browser context. But when you create extra pages via browser.newPage(), those pages don't get cleaned up until the entire test suite finishes. Not individual tests—the whole suite.

So our test suite with 200+ messaging scenarios was creating:

  • 200 tests × 2 pages per test = 400+ browser pages
  • All staying open simultaneously
  • Each consuming memory
  • Jenkins eventually running out of RAM
  • Tests timing out before they could even start

The cruel irony? Playwright's isolation was too good. Each test was perfectly isolated from others, but the pages themselves were living forever in browser limbo.

#The Local vs CI Paradox

Locally, everything worked fine. Why? Because developers typically run a subset of tests, and our laptops have enough RAM to handle a few dozen browser pages. But in CI, where the full suite runs uninterrupted, those pages accumulate like digital hoarding.

We discovered the pattern across our different test types:

Multi-user workflows: Admin approves, user receives notification (2 pages) Cross-platform testing: Desktop view vs mobile view (2 pages)
A/B testing verification: Control vs variant experiences (2+ pages) Real-time collaboration: Multiple users editing simultaneously (3-5 pages)

Each scenario was perfectly valid and necessary. But CI was treating our test suite like a browser tab addiction support group.

#The Cleanup Chaos

Our first attempt was the obvious one: manual cleanup.

typescript
test('messaging with manual cleanup', async ({ browser }) => {
const senderPage = await browser.newPage()
const recipientPage = await browser.newPage()
try {
// Test logic here...
} finally {
await senderPage.close()
await recipientPage.close()
}
})

This worked... until it didn't. Tests that threw exceptions before reaching the finally block. Async operations that hung. Network timeouts that left pages in weird states.

We ended up with:

typescript
test('messaging with paranoid cleanup', async ({ browser }) => {
let senderPage, recipientPage
try {
senderPage = await browser.newPage()
recipientPage = await browser.newPage()
// Test logic...
} finally {
if (senderPage && !senderPage.isClosed()) {
await senderPage.close().catch(console.error)
}
if (recipientPage && !recipientPage.isClosed()) {
await recipientPage.close().catch(console.error)
}
}
})

Multiply this everywhere. Every test became a defensive programming exercise. Our test code was 40% actual testing, 60% memory management ceremony.

#The Jenkins Memory Battle

Meanwhile, our DevOps team was frantically adjusting Jenkins configurations:

yaml
# Increase memory allocation (spoiler: didn't help)
memory: 8GB → 16GB → 32GB
# Reduce parallel workers (slowed everything down)
workers: 8 → 4 → 2 → 1
# Add aggressive timeouts (killed valid long-running tests)
timeout: 30s → 10s

We were treating the symptoms, not the disease. More memory just meant we could accumulate more leaked pages before crashing. Fewer workers meant longer build times. Aggressive timeouts meant false negatives.

The real problem wasn't memory—it was lifecycle management.

#The Eureka Moment

During one particularly frustrating debugging session, I noticed something odd. When I manually closed a browser window in headed mode, the test would suddenly start behaving normally. It wasn't a test logic problem—it was a resource management problem.

That's when it hit me: What if page cleanup was automatic and reliable, just like Playwright's context isolation?

I wanted the same confidence I had with contexts—knowing that every test starts clean and finishes clean, regardless of what happens in between.

#Building the Solution

I spent the next few days building what would become Playwright PageMan. The concept was elegantly simple:

  1. Track every extra page created during test execution
  2. Auto-close them after each test via Playwright's fixture lifecycle
  3. Handle failures gracefully so cleanup always happens
  4. Make it automatic for the common case (browser.newPage())

Here's how the same test looks with PageMan:

typescript
import { test, expect } from 'playwright-pageman'
test('user can send and receive messages', async ({ browser }) => {
// This page is automatically tracked!
const senderPage = await browser.newPage()
await senderPage.goto('/chat/sender-view')
// This page is automatically tracked too!
const recipientPage = await browser.newPage()
await recipientPage.goto('/chat/recipient-view')
// ... test both sides of the conversation ...
// No cleanup needed! Pages auto-close after the test.
})

That's it. No try-finally blocks. No manual cleanup. No defensive programming. Just the test logic that actually matters.

#Auto-Tracking Magic

The secret sauce is automatic tracking. PageMan intercepts browser.newPage() calls and automatically adds them to a cleanup queue. When the test finishes (whether it passes, fails, or times out), Playwright's fixture system ensures all tracked pages get closed.

For the less common cases—pages created via context.newPage() or popup windows—you can manually track them:

typescript
test('handle popup messages', async ({ page, context, extraPages }) => {
// Open a popup window
const [popup] = await Promise.all([
context.waitForEvent('page'),
page.click('#open-conversation-popup'),
])
// Track it for auto-cleanup
extraPages.push(popup)
// Test the popup interface...
// Popup auto-closes after the test
})

#The CI Transformation

After rolling out PageMan, our Jenkins builds went from disaster to delight:

Before PageMan:

  • ❌ Builds timing out after 45 minutes
  • ❌ Out of memory errors
  • ❌ 800+ leaked browser windows
  • ❌ Tests failing due to resource exhaustion
  • ❌ Manual cleanup everywhere

After PageMan:

  • ✅ Reliable 15-minute build times
  • ✅ Stable memory usage (under 2GB)
  • ✅ Zero leaked pages
  • ✅ Tests failing only when they should
  • ✅ Clean, focused test code

#Real-World Benefits

The impact went beyond just CI stability:

For QA Engineers:

  • Write tests that focus on behavior, not cleanup
  • No more defensive try...finally everywhere
  • Reliable execution in both local and CI environments

For DevOps Teams:

  • Predictable resource usage in CI pipelines
  • Smaller Jenkins instances (saved actual money)
  • No more 3 AM alerts about crashed test runners

For Development Teams:

  • Faster feedback loops with stable CI
  • More confidence in test results
  • Multi-page testing patterns become trivial

#The Global Access Innovation

One of the most requested features came from page object models. Teams wanted to track pages created inside helper functions without passing fixtures around:

typescript
// helpers/conversation-helper.ts
import { extraPages } from 'playwright-pageman'
export class ConversationHelper {
async openNewChatWindow(context: BrowserContext, userId: string) {
const page = await context.newPage()
extraPages.push(page) // Global access - no fixture passing!
await this.loginAsUser(page, userId)
return page
}
}

This made PageMan even more seamless—no architectural changes needed, just better lifecycle management.

#The Open Source Journey

After seeing the transformation in our own testing workflows, I realized this was a universal problem. Every team doing complex end-to-end testing faces the same page management challenges:

  • E-commerce sites: Testing both customer and admin interfaces
  • Collaboration tools: Multiple users interacting simultaneously
  • Real-time applications: Different viewport experiences
  • Multi-tenant platforms: Various user role perspectives

So I open-sourced Playwright PageMan with:

  • Zero configuration setup (just change your import)
  • Auto-tracking enabled by default (handles 80% of cases automatically)
  • Manual tracking for special cases (popups, context pages, etc.)
  • Configurable timeouts and logging (for debugging edge cases)
  • Full TypeScript support (because types prevent bugs)

#Configuration for Different Environments

PageMan adapts to your workflow:

typescript
// For local development - see what's happening
test.use({
pageManOptions: {
logCleanup: true, // Log cleanup actions
closeTimeout: 5000, // Patient with slow pages
},
})
// For CI - fast and silent
test.use({
pageManOptions: {
logCleanup: false, // No noise in CI logs
closeTimeout: 1000, // Aggressive timeouts
},
})
// For debugging - manual control
test.use({
pageManOptions: {
autoTrack: false, // Manual tracking only
},
})

#The Community Response

Since going open source, PageMan has attracted teams with even more creative use cases:

  • Visual regression testing: Comparing designs across multiple viewport sizes
  • Accessibility testing: Testing screen readers in multiple windows simultaneously
  • Load testing: Simulating multiple user sessions from a single test
  • Cross-browser workflows: Coordinating actions between different browser instances

The common thread? Every team was fighting the same page lifecycle battle, and PageMan made it disappear.

#Looking Forward

Today, PageMan manages page lifecycles for thousands of tests across dozens of CI environments. What started as a weekend fix for our Jenkins memory crisis has become a tool that makes multi-page testing humane.

The lesson? Sometimes the best tools aren't about adding new capabilities—they're about removing friction from what you already know how to do.

If your CI builds are mysteriously slow, if your Jenkins instances keep running out of memory, if you're writing more cleanup code than test logic—you're not alone. And maybe, just maybe, your test runner is hosting an involuntary browser tab convention too.

#Get Started

Want to stop the page leak crisis in your tests? PageMan takes 30 seconds to install:

bash
npm install playwright-pageman
typescript
// Replace this:
import { test, expect } from '@playwright/test'
// With this:
import { test, expect } from 'playwright-pageman'
// That's it! Auto-tracking is enabled by default.
test('your test', async ({ browser }) => {
const page1 = await browser.newPage() // Automatically tracked
const page2 = await browser.newPage() // Also automatically tracked
// Both pages auto-close after the test
})

No more manual cleanup. No more leaked pages. No more Jenkins tab parties.

Sometimes the smartest thing you can do is let the computer handle the boring stuff, so you can focus on what actually matters—making sure your conversation works from both sides.


Fighting your own page leak crisis? Found PageMan useful for your multi-page testing scenarios? Share your experience or contribute to the project. Let's make testing cleaner, one auto-closed page at a time.