◉ANTIGRAVITY LABJP
Articles/Agents & Manager
◈ Agents & Manager/2026-05-26Intermediate

Why Antigravity's Browser Sub-Agent Reads SPAs as Empty Pages — and Three Wait Strategies That Stuck for Me

When you hand an SPA dashboard to Antigravity's Browser Sub-Agent, get_page_text often returns before the real content is rendered, and the agent reports an empty page. Here is how I diagnose the symptom and the three wait strategies that have stabilized my routine.

antigravity461browser-sub-agent3spatroubleshooting113agents144wait-strategy

One morning I asked Antigravity's Browser Sub-Agent to read my AdMob console for me. The return value of get_page_text was just "Sign in" and "Loading…". The browser address bar had clearly moved on to the dashboard, but from the agent's point of view the body of the page was still empty. The instruction was "find the networks whose eCPM has dropped this week," and the agent — taking the empty text at face value — replied "none found."

I have been running a portfolio of mobile apps solo since 2014, now at about fifty million cumulative downloads, and Browser Sub-Agents reading SPA dashboards like AdMob, App Store Connect, and Stripe Dashboard have become a regular part of my workflow. The same misread happened to me several times a week at first, but once I broke the symptom apart it became clear that the problem was a mismatch in wait strategy. The same approach now powers the four sites I run under Dolice Labs, so I want to write down the diagnostic flow I have settled on and the three wait patterns that have proven reliable.

Three different states can look like "an empty page"

The first thing that matters is that "empty" is actually three different states. If you treat them as one and just turn up the wait time, the behaviour never quite stabilizes.

The first state is the SPA shell. The HTML contains only <div id="root"></div> and a handful of meta tags; the body content is injected by client JavaScript later. When the result of get_page_text is unusually short and contains only generic strings like "Loading…" or "Sign in to continue," you are in this state.

The second state is being bounced back to a login page by an auth guard. document.title may still read like the app's title, but the body is just a sign-in form. This often happens when last week's session cookie has expired — AdMob expires in about seven days, App Store Connect in roughly eight hours.

The third state is when the DOM is fully built but the data rows are still waiting on an API response. Table headers are visible, rows show "No data" or empty cells. This is purely network-bound and goes away if you wait a bit longer.

The first step in diagnosis is always to look at the HTML structure. Pairing get_page_text with get_page_html lets you tell the three apart in a few seconds.

The root cause is the gap between DOMContentLoaded and hydration

By default, a Browser Sub-Agent treats a page as "loaded" right after navigate returns. That judgment is roughly based on the load event, when the HTML and external resources have all been fetched. SPAs built with React, Vue, or the Next.js App Router only evaluate their JS bundle, run hydration, and finish the initial data fetch after load. The screen does not show meaningful text until that pipeline completes.

In other words, the moment the Sub-Agent says "ready to read" and the moment you, as a human, see the dashboard appear are separated by two to six seconds on an SPA. On the AdMob dashboard on my setup the median was about 3.2 seconds and the 95th percentile was 5.8 seconds. Covering 95% of cases with a fixed sleep would cost six seconds every time and burn execution time and tokens for nothing.

To avoid that, you need a structural way to detect that the page is actually ready. Here are the three patterns that stabilized for me, in order.

Pattern 1: Wait for a known selector to render

The most reliable and least false-positive pattern is to declare "the body is ready when this selector appears." Inside an Antigravity Browser Sub-Agent you can either install a MutationObserver via javascript_tool or, more simply, poll. Polling is usually enough.

// Run via javascript_tool from the Browser Sub-Agent
async function waitForSelector(selector, timeoutMs = 10000) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const el = document.querySelector(selector);
    if (el && el.offsetParent !== null) return true;
    await new Promise(r => setTimeout(r, 200));
  }
  throw new Error(`Selector not visible: ${selector}`);
}
 
// Wait for the AdMob mediation table to render
await waitForSelector('table[data-test="mediation-waterfall"] tbody tr');

The detail that matters is offsetParent !== null — it confirms not only that the element exists in the DOM but also that it is not display:none. SPAs often insert elements first and reveal them afterwards, so skipping this check will cause false-positive successes. For AdMob I look for tbody tr, but when I want to distinguish "render complete with zero rows" from "still rendering," I add an empty-state selector in an OR clause, such as tbody tr, [data-empty-state].

Pattern 2: Network idle plus structural verification

For pages where you cannot pin down a selector in advance — for example when the agent is given an open-ended instruction like "grab the latest error visible on the screen" — wait for the network to settle, then verify the structure.

// Wait until network has been idle for N milliseconds
async function waitForNetworkIdle(idleMs = 800, timeoutMs = 12000) {
  return new Promise((resolve, reject) => {
    const start = Date.now();
    let lastActivity = Date.now();
    let pending = 0;
 
    const origFetch = window.fetch;
    window.fetch = async function (...args) {
      pending++;
      lastActivity = Date.now();
      try {
        return await origFetch.apply(this, args);
      } finally {
        pending--;
        lastActivity = Date.now();
      }
    };
 
    const tick = setInterval(() => {
      const idle = Date.now() - lastActivity;
      if (pending === 0 && idle >= idleMs) {
        clearInterval(tick);
        window.fetch = origFetch;
        resolve(true);
      } else if (Date.now() - start > timeoutMs) {
        clearInterval(tick);
        window.fetch = origFetch;
        reject(new Error("Network idle timeout"));
      }
    }, 100);
  });
}
 
await waitForNetworkIdle(800);
 
// Structural verification: a heading and a table at minimum
const ok = document.querySelectorAll('h1, h2, h3').length > 0
        && document.querySelectorAll('table, [role="grid"]').length > 0;
if (!ok) throw new Error("Page structure not ready");

Wrapping fetch will miss XHR traffic, so if the screen still relies on older XHR-based code, hook XMLHttpRequest.prototype.send in the same way. I added an XHR hook for a few App Store Connect screens, which is what finally made them reliable.

Pattern 3: A retry-first agent prompt

There is also the option of not over-engineering on the runtime side and instead baking the behaviour into the Sub-Agent's instructions: "if get_page_text returns something short or only contains keywords like 'Loading,' wait three seconds and retry. After three failures, summarize the situation and ask."

# Shared prompt fragment for the Sub-Agent
 
When reading a page, follow these rules.
 
1. Call get_page_text right after navigate.
2. If the result is shorter than 200 characters, or only contains "Loading" / "Sign in,"
   wait 3 seconds and call get_page_text again. Up to three retries.
3. If all three retries fail, report "page may not have rendered" with the current
   URL, title, and the first 500 characters, and ask for human judgment.
4. Treat the read as successful only when the expected keywords
   (for example "Mediation", "Waterfall") appear.

The implementation cost of this pattern is essentially zero, but token usage goes up. In practice I use Pattern 1 selector waits for high-frequency dashboards like AdMob, App Store Connect, and Stripe Dashboard, and Pattern 3 retries for admin screens I open only once a month.

Two habits that prevent this from coming back

Beyond the wait pattern itself, two operational habits raise the floor of stability noticeably. The first is to make the Sub-Agent itself responsible for "is this result obviously too short?" and "is the expected keyword completely missing?" Don't just look at the raw text length — define, per domain, a label that absolutely has to appear on this page, and key the success judgment off that. Misreads drop close to zero.

The second is to write session cookie lifetimes into your operational calendar. AdMob lasts about seven days, App Store Connect roughly eight hours, Stripe Dashboard via CSR about two weeks, and Cloudflare Dashboard around twelve hours with MFA enabled. Half of the "the Sub-Agent says it can't read it" complaints I get are really sessions, so reserving a single block of time on Monday to refresh the sessions you need that week makes a lot of mystery waits disappear.

That is the full picture of how I work the SPA-versus-Sub-Agent friction into my routine. Thank you for reading. As a concrete next step, pick the one dashboard you have the agent read most often and write down a single selector that would mean "this page is ready." Just that one line will visibly raise the trustworthiness of what the agent returns.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◈ Agents & Manager2026-09-04
Snapshot Your Agents' Effective Scope So You Notice the Day a Default Changes
Antigravity 1.1.25 made Markdown-defined custom agents inherit the surrounding skills, rules, and subagents by default. Here is the snapshot-and-diff routine I now use to check whether an agent I meant to keep narrow quietly got wider after an update.
◈ Agents & Manager2026-08-28
Git Tracks the File, but Your Agent Can't See It. Here's Where It Drops Out
An agent's code search returned two files. git grep returned six. I traced the three paths that quietly drop tracked files out of search, measured them on a synthetic repo and a production one, and worked out how to spot the gap in a single line.
◈ Agents & Manager2026-08-21
Why I Check Description Overlap Before Turning On inheritCustomizations
CLI 1.1.14 collapsed markdown agent inheritance into a single inheritCustomizations switch. Here is what happened when I actually inventoried the 47 skills in my workspace and decided the switch on description overlap rather than on context size.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links