The "Resilient UX"
Checklist
How to test the "Zombie UI" 🧟♂️ and ensure your app feels responsive even when the network isn't.
The Problem: The "Zombie UI" 🧠
The Zombie UI
Resilient Feedback
You click "Submit". The database is working hard. The API is processing perfectly. 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 UI's reaction.
According to Nielsen's "Rules of Response Time", if your UI freezes for more than 1 second without feedback, the user loses focus and suspects the app has crashed. They will refresh the page or rage-click the button.
Timeline Comparison
The Solution: The 3-Step Feedback Loop
Don't just assert expect(success_message).to_be_visible(). You must assert the intermediate states.
- Immediate (<100ms): The button MUST become disabled. (Prevents Rage Clicks).
- Short Wait (300ms): A spinner or skeleton loader MUST appear.
- Long Wait (>3000ms): If the network is terrible, show a "This is taking longer than usual..." toast. Never leave the user staring at a spinning circle forever.
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".
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...")
time.sleep(3) # Simulate Bad 3G
# Note: In async implementation, use 'await asyncio.sleep(3)' to avoid blocking the loop
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 to prevent double-charging
expect(submit_btn).to_be_disabled()
# ✅ 4. Assert "Loading State" (100-300ms)
# The spinner must appear while we wait
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)
expect(spinner).not_to_be_visible()
Are your users staring at a frozen screen?
Use Chaos Proxy to inject 3s latency and ensure your loading states actually appear. Fix the Dead Zone.
No code required
Architecture Note: CDP vs. System Proxy 🏗️
Most of these tips use Playwright's network interception, which relies on the Chrome DevTools Protocol (CDP).
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 in a device farm.
If you want to run these Chaos scenarios on real physical devices, code-based throttling isn't enough. You need a System Level Proxy (like Chaos Proxy) that sits between the physical device and the internet.
Why this matters
Perceived Performance > Actual Performance.You cannot always fix the slow database query. You cannot fix the user's bad 4G connection. But you can fix how the UI communicates that delay. This checklist ensures that even when the world is slow, your app feels responsive.