DEV Community

Ilya Ploskovitov
Ilya Ploskovitov

Posted on • Originally published at chaos-proxy.debuggo.app

Stop Building "Zombie UI": The Resilient UX Checklist (Playwright + Python)

The Problem: The "Zombie UI" 🧠

You click "Submit". The database is writing data. The API is processing the request perfectly. The backend is healthy. But on the screen... nothing happens.

The button still looks clickable. The cursor is still a pointer. This is the "Dead Zone" — the gap between the user's input and the interface's reaction.

According to Jakob Nielsen's Response Time Limits, you have a strict budget:

  • 0 - 100ms: Instant. Feels like manipulating a physical object.
  • 100 - 300ms: Slight delay. Acceptable, but noticeable.
  • 300 - 1000ms: User loses focus. "Is it working? Did I miss the button?"
  • > 1000ms: Zombie Mode. The user thinks the app crashed. They will refresh the page or rage-click the button, triggering duplicate transactions.

A working backend is not enough. If your UI freezes for 2 seconds without feedback, your feature is broken.



The Solution: The 3-Step Feedback Loop

We are used to writing tests like this: expect(success_message).to_be_visible(). But that is not enough. We must assert the intermediate states.

✅ I use this Resilient UX Checklist for every async action:
The Checklist

  • Immediate (<100ms): The button MUST become disabled. This prevents Rage Clicks and double-charges.
  • Short Wait (300ms): A spinner or skeleton loader MUST appear.
  • Long Wait (>3000ms): If the network is terrible (e.g., subway tunnel), show a "This is taking longer than usual..." toast. Never leave the user staring at an infinite spinner.

The Code (Python + Playwright)

How do we test this? We can't rely on random network lag. We need to deliberately freeze the request for 3 seconds to verify the application's "Patience Logic".

Here is a Playwright test that guarantees the "Zombie UI" never happens:

import time
from playwright.sync_api import Page, Route, expect

def test_slow_network_ux(page: Page):
    # 🛑 1. Setup the "Freeze" Interceptor
    def slow_handler(route: Route):
        print(f"❄️ Freezing request to {route.request.url} for 3s...")
        # Simulate Bad 3G / Subway Network
        # Note: In async tests, use 'await asyncio.sleep(3)'
        time.sleep(3) 
        route.continue_()

    # Intercept the checkout API
    page.route("**/api/checkout", slow_handler)

    page.goto("/cart")

    # 🎬 2. Trigger the Action
    submit_btn = page.locator("#submit-order")
    submit_btn.click()

    # ✅ 3. Assert "Immediate Feedback" (0-100ms)
    # The button must be disabled immediately
    expect(submit_btn).to_be_disabled()

    # ✅ 4. Assert "Loading State" (100-300ms)
    # The spinner must appear while we wait (we have 3 seconds)
    spinner = page.locator(".spinner-loader")
    expect(spinner).to_be_visible()

    # ✅ 5. Assert "Success State" (After 3s)
    # Eventually, the request completes
    expect(page.locator(".success-message")).to_be_visible(timeout=5000)
    # The spinner should disappear
    expect(spinner).not_to_be_visible()

Enter fullscreen mode Exit fullscreen mode

Why automate this?

If you test this manually on localhost, you will blink and miss the spinner. By forcing a 3-second delay in CI, you guarantee that every user—even those on a slow mobile connection—gets a responsive UI, not a dead one.


Architecture Note: CDP vs System Proxy 🏗️

Why your local tests might be lying to you.

Most network interception tests (like the one above) use the Chrome DevTools Protocol (CDP).

  • CDP is great for Browsers: It gives you perfect control over traffic inside the Chrome process.
  • CDP fails on "The Full Matrix": You cannot easily attach CDP to a physical iPhone running Safari, a Smart TV app, or a native Android build.

The "Real World" Reality

If you want to run these Chaos scenarios on real physical devices (not emulators), code-based interception isn't enough.

You need a System Level Proxy (like Charles Proxy or a cloud-native tool like Chaos Proxy Debuggo). These tools sit between the physical device and the internet, allowing you to apply "3G Throttling" or "Random 500 Errors" to a real iPhone without changing a single line of your app's code.

Conclusion

Perceived Performance > Actual Performance.

You cannot always fix the slow SQL query. You cannot fix the user's spotty 4G connection. But you can fix how your UI communicates that delay.

Top comments (0)