← Back to Blog
Developer Experience11 minFebruary 27, 2026

Load testing your signup flow with AI agents: a methodology

A
Anon Team

You've optimized your signup flow for humans. You've A/B tested button colors, reduced form fields, and streamlined email verification. But you've never tested what happens when 50 AI agents hit your signup page simultaneously.

This matters because AI agent traffic is growing fast. Imperva's 2025 Bad Bot Report found that automated traffic now accounts for 51% of all web traffic. Gartner projects 60% of enterprise workflows will involve AI agents by 2026. And from our teardown of 20 SaaS signup flows, most products aren't ready.

The question isn't just "can an agent sign up?" — it's "can 50 agents sign up in the same minute without your system falling over?"

Here's the methodology.

Why agent load testing is different

Standard load testing (k6, Locust, Artillery) simulates HTTP request patterns. That's great for API endpoints but misses the entire agent signup experience because:

  1. Agents use real browsers. AI agents built on Playwright, Puppeteer, or Selenium render JavaScript, execute CSS, interact with DOM elements, and manage cookies. A simple HTTP load test doesn't test any of this.

  2. Agents navigate multi-step flows. A signup wizard with 3 steps, email verification, and dashboard onboarding involves 15+ HTTP requests with state dependencies between them. Load testing individual endpoints misses the interaction.

  3. Agents trigger unique server-side behavior. CAPTCHA services, email verification systems, OAuth token generation, and API key provisioning all have their own capacity limits. An agent load test exercises all of these simultaneously.

  4. Agents have different timing patterns. Humans take 30-60 seconds to fill out a form. Agents complete it in 2-3 seconds. This compresses the load pattern and can trigger rate limiters that humans never encounter.

The test architecture

We use Artillery + Playwright for browser-based load testing. Artillery handles orchestration, parallelism, and metrics collection. Playwright handles the actual browser interaction.

┌────────────────────────────────────┐
│           Artillery                │
│  (Load generation, orchestration)  │
│                                    │
│  ┌──────┐ ┌──────┐ ┌──────┐      │
│  │ VU 1 │ │ VU 2 │ │ VU N │      │  VU = Virtual User
│  │(Chro)│ │(Chro)│ │(Chro)│      │  Each runs headless Chrome
│  └──┬───┘ └──┬───┘ └──┬───┘      │
│     │        │        │           │
└─────┼────────┼────────┼───────────┘
      │        │        │
      ▼        ▼        ▼
┌────────────────────────────────────┐
│        Your Signup Flow            │
│  (Server, auth, email, CAPTCHA)    │
└────────────────────────────────────┘

Each virtual user is a full headless Chrome instance running your Playwright test script. This is resource-intensive — 50-100 concurrent browsers is usually the practical limit per machine. But that's enough to find most problems.

Phase 1: Single-agent baseline

Before load testing, establish that a single agent can complete your entire signup flow. This is your smoke test.

// tests/signup-baseline.spec.ts
import { test, expect, type Page } from '@playwright/test';

// Generate unique test data for each run
function generateTestUser() {
  const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
  return {
    email: `agent-test-${id}@yourdomain-test.com`,
    password: `AgentTest!${id}2026`,
    name: `Agent Test ${id}`,
  };
}

test('complete signup flow as agent', async ({ page }) => {
  const user = generateTestUser();
  const timings: Record<string, number> = {};

  // Step 1: Navigate to signup
  const navStart = Date.now();
  await page.goto('https://yourdomain.com/signup');
  timings['navigation'] = Date.now() - navStart;

  // Step 2: Check for CAPTCHA (immediate failure signal)
  const hasCaptcha = await page.locator('[data-sitekey], .g-recaptcha, .cf-turnstile, .h-captcha')
    .count();
  if (hasCaptcha > 0) {
    console.warn('⚠️ CAPTCHA detected — agents will be blocked here');
    // Log but continue — the CAPTCHA might be invisible/passthrough
  }

  // Step 3: Check for OAuth buttons
  const hasOAuth = await page.locator(
    'button:has-text("Google"), button:has-text("GitHub"), a:has-text("Continue with")'
  ).count();
  console.log(`OAuth options available: ${hasOAuth > 0 ? 'Yes' : 'No'}`);

  // Step 4: Fill signup form
  const fillStart = Date.now();

  // Try email field (various selectors agents might encounter)
  const emailField = page.locator(
    'input[type="email"], input[name="email"], input[placeholder*="email" i]'
  ).first();
  await emailField.fill(user.email);

  // Try password field
  const passwordField = page.locator(
    'input[type="password"], input[name="password"]'
  ).first();
  if (await passwordField.count() > 0) {
    await passwordField.fill(user.password);
  }

  // Try name field (if present)
  const nameField = page.locator(
    'input[name="name"], input[name="fullName"], input[placeholder*="name" i]'
  ).first();
  if (await nameField.count() > 0) {
    await nameField.fill(user.name);
  }

  timings['form_fill'] = Date.now() - fillStart;

  // Step 5: Submit
  const submitStart = Date.now();
  const submitButton = page.locator(
    'button[type="submit"], button:has-text("Sign up"), button:has-text("Create account"), button:has-text("Get started")'
  ).first();
  await submitButton.click();

  // Step 6: Wait for response
  await page.waitForURL(/\/(dashboard|verify|confirm|welcome)/, { timeout: 15000 })
    .catch(() => {
      // Might stay on same page with success message
    });

  timings['submission'] = Date.now() - submitStart;

  // Step 7: Check result
  const currentUrl = page.url();
  const pageContent = await page.textContent('body');

  const success = currentUrl.includes('dashboard') ||
    currentUrl.includes('welcome') ||
    currentUrl.includes('verify') ||
    pageContent?.includes('check your email') ||
    pageContent?.includes('account created');

  console.log(`Signup result: ${success ? '✅ Success' : '❌ Failed'}`);
  console.log(`Final URL: ${currentUrl}`);
  console.log(`Timings:`, timings);
  console.log(`Total: ${Object.values(timings).reduce((a, b) => a + b, 0)}ms`);

  expect(success).toBeTruthy();
});

Run it:

npx playwright test tests/signup-baseline.spec.ts --headed

Use --headed to watch the browser. You'll immediately see issues an HTTP test would miss: JavaScript rendering delays, CAPTCHA challenges, form validation errors, redirect chains.

What to look for in the baseline:

  • Total time from page load to account creation
  • Whether CAPTCHAs appear (and whether they auto-solve for non-suspicious traffic)
  • Whether the form is accessible without JavaScript (SSR)
  • Error messages that aren't machine-readable
  • Redirect chains that could confuse navigation tracking

Phase 2: Rate limit discovery

Before running a full load test, find your rate limits. Many SaaS products have undocumented rate limits on signup that agents hit but humans don't.

// tests/rate-limit-discovery.ts
import { chromium, type Browser, type Page } from 'playwright';

const TARGET_URL = 'https://yourdomain.com/signup';
const ATTEMPTS_PER_MINUTE = [1, 5, 10, 20, 50];

async function testRateLimit(browser: Browser, attemptsPerMinute: number) {
  const results: { attempt: number; status: string; responseTime: number }[] = [];
  const interval = 60000 / attemptsPerMinute;

  for (let i = 0; i < attemptsPerMinute; i++) {
    const start = Date.now();
    const context = await browser.newContext();
    const page = await context.newPage();

    try {
      const response = await page.goto(TARGET_URL, { timeout: 10000 });
      const status = response?.status() ?? 0;

      // Check for rate limit indicators
      const body = await page.textContent('body').catch(() => '');
      const isRateLimited =
        status === 429 ||
        status === 403 ||
        body?.includes('rate limit') ||
        body?.includes('too many requests') ||
        body?.includes('try again later') ||
        body?.includes('blocked');

      results.push({
        attempt: i + 1,
        status: isRateLimited ? `RATE_LIMITED (${status})` : `OK (${status})`,
        responseTime: Date.now() - start,
      });
    } catch (err: any) {
      results.push({
        attempt: i + 1,
        status: `ERROR: ${err.message?.slice(0, 80)}`,
        responseTime: Date.now() - start,
      });
    }

    await context.close();

    // Wait for next interval
    const elapsed = Date.now() - start;
    if (elapsed < interval) {
      await new Promise(r => setTimeout(r, interval - elapsed));
    }
  }

  return results;
}

async function main() {
  const browser = await chromium.launch({ headless: true });

  for (const rate of ATTEMPTS_PER_MINUTE) {
    console.log(`\n--- Testing ${rate} attempts/minute ---`);
    const results = await testRateLimit(browser, Math.min(rate, 10)); // Cap actual attempts
    
    const blocked = results.filter(r => r.status.includes('RATE_LIMITED') || r.status.includes('ERROR'));
    const avgTime = results.reduce((s, r) => s + r.responseTime, 0) / results.length;

    console.log(`  Attempts: ${results.length}`);
    console.log(`  Blocked: ${blocked.length} (${(100 * blocked.length / results.length).toFixed(0)}%)`);
    console.log(`  Avg response time: ${avgTime.toFixed(0)}ms`);

    if (blocked.length > 0) {
      console.log(`  ⚠️ Rate limit hit at ${rate}/min`);
      console.log(`  First block at attempt #${blocked[0].attempt}`);
      break;
    }
  }

  await browser.close();
}

main();

Common findings:

  • Many SaaS products rate-limit at 10-20 requests per IP per minute on signup pages — fine for humans, death for agents
  • Cloudflare's bot protection can trigger at 5+ rapid page loads from the same IP
  • Some products use progressive rate limiting: first few requests are fine, then response times increase before eventually blocking

Phase 3: Concurrent load test

Now the real test. Use Artillery + Playwright to simulate multiple agents signing up simultaneously:

# artillery-signup-load.yml
config:
  target: "https://yourdomain.com"
  phases:
    # Warm-up: 1 agent per second for 30 seconds
    - duration: 30
      arrivalRate: 1
      name: "Warm-up"
    # Ramp: increase to 5 agents per second over 60 seconds
    - duration: 60
      arrivalRate: 1
      rampTo: 5
      name: "Ramp-up"
    # Sustained: hold at 5 agents per second for 120 seconds
    - duration: 120
      arrivalRate: 5
      name: "Sustained load"
    # Spike: burst to 10 agents per second for 30 seconds
    - duration: 30
      arrivalRate: 10
      name: "Spike test"
  engines:
    playwright:
      launchOptions:
        headless: true
        args:
          - "--disable-gpu"
          - "--no-sandbox"
          - "--disable-dev-shm-usage"
      contextOptions:
        # Each VU gets a unique viewport (avoids fingerprint correlation)
        viewport:
          width: 1280
          height: 720

scenarios:
  - engine: playwright
    testFunction: "agentSignup"
// signup-flow.ts — Artillery Playwright test function
import { type Page } from 'playwright';

export async function agentSignup(page: Page, vuContext: any, events: any) {
  const testId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
  const email = `loadtest-${testId}@yourtestdomain.com`;
  const startTime = Date.now();

  try {
    // Navigate to signup
    const navResponse = await page.goto('/signup', {
      waitUntil: 'networkidle',
      timeout: 15000,
    });

    events.emit('counter', 'signup.page_loaded', 1);
    events.emit('histogram', 'signup.navigation_ms', Date.now() - startTime);

    if (!navResponse || navResponse.status() >= 400) {
      events.emit('counter', 'signup.navigation_failed', 1);
      return;
    }

    // Fill email
    const fillStart = Date.now();
    await page.fill('input[type="email"], input[name="email"]', email, {
      timeout: 5000,
    });

    // Fill password (if present)
    const passwordField = page.locator('input[type="password"]').first();
    if (await passwordField.isVisible({ timeout: 1000 }).catch(() => false)) {
      await passwordField.fill(`LoadTest!${testId}`);
    }

    events.emit('histogram', 'signup.form_fill_ms', Date.now() - fillStart);

    // Submit
    const submitStart = Date.now();
    await page.click('button[type="submit"], button:has-text("Sign up")');

    // Wait for result (either redirect or on-page feedback)
    await Promise.race([
      page.waitForURL(/\/(dashboard|verify|welcome)/, { timeout: 10000 }),
      page.waitForSelector('.success, .error, [role="alert"]', { timeout: 10000 }),
    ]).catch(() => {});

    const submitTime = Date.now() - submitStart;
    events.emit('histogram', 'signup.submit_ms', submitTime);

    // Check outcome
    const url = page.url();
    const body = await page.textContent('body').catch(() => '');

    if (url.includes('dashboard') || url.includes('welcome') ||
        body?.includes('check your email') || body?.includes('created')) {
      events.emit('counter', 'signup.success', 1);
    } else if (body?.includes('rate limit') || body?.includes('too many')) {
      events.emit('counter', 'signup.rate_limited', 1);
    } else if (body?.includes('already exists') || body?.includes('already registered')) {
      events.emit('counter', 'signup.duplicate_email', 1);
    } else {
      events.emit('counter', 'signup.unknown_outcome', 1);
    }

    events.emit('histogram', 'signup.total_ms', Date.now() - startTime);

  } catch (error: any) {
    events.emit('counter', 'signup.error', 1);
    events.emit('counter', `signup.error.${error.name || 'unknown'}`, 1);
  }
}

Run it:

npx artillery run artillery-signup-load.yml

Phase 4: What to measure

The load test generates metrics. Here's what matters and what the thresholds should be:

Core metrics

Metric Good Warning Failing
signup.navigation_ms (p95) < 3,000ms 3–8,000ms > 8,000ms
signup.form_fill_ms (p95) < 500ms 500–2,000ms > 2,000ms
signup.submit_ms (p95) < 5,000ms 5–15,000ms > 15,000ms
signup.total_ms (p95) < 10,000ms 10–30,000ms > 30,000ms
signup.success rate > 95% 80–95% < 80%
signup.rate_limited rate < 2% 2–10% > 10%
signup.error rate < 1% 1–5% > 5%

Derived metrics

Agent throughput: Successful signups per minute at sustained load. This tells you the maximum rate at which agents can onboard.

Rate limit ceiling: The request rate at which your first rate-limited responses appear. If it's below 10/minute from a single IP, you're too aggressive.

Error budget under load: The percentage of errors that appear only at load (vs. baseline). These are concurrency bugs — race conditions in email uniqueness checks, database connection pool exhaustion, auth token generation bottlenecks.

CAPTCHA trigger rate: If using invisible CAPTCHAs (reCAPTCHA v3, Turnstile), what percentage of agents get challenged at load? This often correlates with request velocity — invisible CAPTCHAs that pass at 1 request/second may block at 5.

Get Started

Ready to make your product agent-accessible?

Add a few lines of code and let AI agents discover, request access, and get real credentials — with human oversight built in.

Get started with Anon →

Phase 5: Testing the full agent journey

Signup is only the beginning. A complete agent journey includes:

// full-journey-test.ts — Complete agent onboarding journey
export async function agentFullJourney(page: Page, vuContext: any, events: any) {
  const testId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
  const startTime = Date.now();

  // Stage 1: Discovery
  // Can the agent find the signup page from the homepage?
  await page.goto('/');
  const signupLink = page.locator('a:has-text("Sign up"), a:has-text("Get started"), a[href*="signup"]').first();

  if (await signupLink.isVisible({ timeout: 3000 }).catch(() => false)) {
    await signupLink.click();
    events.emit('counter', 'journey.signup_found', 1);
  } else {
    await page.goto('/signup');
    events.emit('counter', 'journey.signup_direct', 1);
  }

  // Stage 2: Account creation
  await page.fill('input[type="email"]', `journey-${testId}@testdomain.com`);
  const pwField = page.locator('input[type="password"]').first();
  if (await pwField.isVisible({ timeout: 1000 }).catch(() => false)) {
    await pwField.fill(`Journey!${testId}`);
  }
  await page.click('button[type="submit"]');
  await page.waitForTimeout(3000); // Wait for post-signup navigation

  events.emit('histogram', 'journey.to_account_ms', Date.now() - startTime);

  // Stage 3: Navigate to API keys / developer settings
  const dashboardStart = Date.now();

  // Try common paths to API keys
  const apiKeyPaths = [
    'a:has-text("Settings")',
    'a:has-text("API")',
    'a:has-text("Developer")',
    'a:has-text("Keys")',
    'a:has-text("Tokens")',
    'a[href*="settings"]',
    'a[href*="api"]',
    'a[href*="developer"]',
  ];

  let foundApiSection = false;
  for (const selector of apiKeyPaths) {
    const link = page.locator(selector).first();
    if (await link.isVisible({ timeout: 1000 }).catch(() => false)) {
      await link.click();
      await page.waitForTimeout(1000);
      foundApiSection = true;
      break;
    }
  }

  events.emit('counter', foundApiSection ? 'journey.api_section_found' : 'journey.api_section_missing', 1);

  // Stage 4: Find or generate API key
  const apiKeyElement = page.locator(
    '[data-testid="api-key"], .api-key, code:has-text("sk_"), code:has-text("pk_"), input[readonly]'
  ).first();

  const hasApiKey = await apiKeyElement.isVisible({ timeout: 5000 }).catch(() => false);
  
  if (hasApiKey) {
    events.emit('counter', 'journey.api_key_obtained', 1);
  } else {
    // Try to generate one
    const generateButton = page.locator(
      'button:has-text("Generate"), button:has-text("Create"), button:has-text("New key")'
    ).first();
    
    if (await generateButton.isVisible({ timeout: 2000 }).catch(() => false)) {
      await generateButton.click();
      events.emit('counter', 'journey.api_key_generated', 1);
    } else {
      events.emit('counter', 'journey.api_key_missing', 1);
    }
  }

  events.emit('histogram', 'journey.total_ms', Date.now() - startTime);
}

The journey scorecard

After running the full journey test, you get a funnel:

Landing page → Signup page:     98% (2% can't find signup link)
Signup page → Form submitted:   95% (5% hit CAPTCHA or form errors)
Form submitted → Account created: 88% (7% rate limited, email errors)
Account created → Dashboard:    80% (8% stuck in verification)
Dashboard → API key obtained:   65% (15% can't navigate to API settings)

That last number — 65% of agents that start your signup flow actually get an API key — is your agent conversion rate. For humans, an equivalent funnel might show 30-40% (they get distracted, abandon the flow). Agents are more determined but less flexible — they complete or fail, rarely abandon.

Common failures and fixes

From running this methodology against the products in our benchmark, here are the most common failures:

1. CAPTCHA under load

Symptom: Invisible CAPTCHAs (reCAPTCHA v3, Turnstile) pass at low load but trigger challenges at 5+ concurrent agents.

Fix: Implement risk-based CAPTCHA that factors in authentication status. Authenticated requests (OAuth, API key) should never see a CAPTCHA. Unknown traffic gets scored — only the highest-risk gets challenged.

2. Email uniqueness race condition

Symptom: Two agents submitting simultaneously with different emails both get "email already exists" errors because the uniqueness check and insert aren't atomic.

Fix: Use database-level unique constraints and handle the constraint violation gracefully — return a clear error, not a 500.

-- Use INSERT ... ON CONFLICT for atomic uniqueness
INSERT INTO users (email, password_hash, created_at)
VALUES ($1, $2, NOW())
ON CONFLICT (email) DO NOTHING
RETURNING id;

3. Rate limiter too aggressive

Symptom: Rate limiters set for human interaction speed (1-2 requests per second) block agents that navigate faster.

Fix: See our post on rate limiting for agents. Use tiered rate limits — higher limits for authenticated traffic, lower for anonymous. Include RateLimit-* headers so agents can self-throttle.

4. OAuth callback bottleneck

Symptom: OAuth consent flow works for one agent but fails when 10 agents try simultaneously — the callback endpoint can't handle concurrent token exchanges.

Fix: Ensure your OAuth callback handler is stateless and horizontally scalable. Use a connection pool for token exchange requests.

5. Email verification timeout

Symptom: Agents that need to check an email inbox as part of verification fail because verification emails arrive after the agent's timeout window.

Fix: Either make email verification async (let users into the product immediately, verify later) or provide an alternative verification path (magic links with longer TTL, OAuth bypasses verification entirely).

The CI/CD integration

Run agent load tests as part of your release pipeline. Here's a GitHub Actions workflow:

# .github/workflows/agent-load-test.yml
name: Agent Load Test
on:
  pull_request:
    paths:
      - 'src/auth/**'
      - 'src/signup/**'
      - 'src/onboarding/**'

jobs:
  agent-load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: |
          npm ci
          npx playwright install chromium

      - name: Run baseline test
        run: npx playwright test tests/signup-baseline.spec.ts

      - name: Run load test (staging)
        run: |
          npx artillery run \
            --target https://staging.yourdomain.com \
            --output results.json \
            artillery-signup-load.yml

      - name: Check thresholds
        run: |
          node -e "
            const r = require('./results.json');
            const p95 = r.aggregate.summaries['signup.total_ms']?.p95 || 0;
            const success = r.aggregate.counters['signup.success'] || 0;
            const total = r.aggregate.counters['vusers.created.total'] || 1;
            const successRate = success / total;
            
            console.log('P95 total time:', p95, 'ms');
            console.log('Success rate:', (successRate * 100).toFixed(1), '%');
            
            if (p95 > 30000) process.exit(1);
            if (successRate < 0.8) process.exit(1);
            console.log('✅ All thresholds passed');
          "

This runs automatically whenever someone changes auth, signup, or onboarding code. If the agent load test fails, the PR can't merge.

Start testing

Here's your implementation checklist:

  1. Today: Run the baseline test against your staging environment. See if a single agent can sign up.
  2. This week: Run the rate limit discovery test. Find where your limits are.
  3. This sprint: Set up the Artillery + Playwright load test. Run it against staging with realistic load patterns.
  4. Ongoing: Add agent load tests to CI/CD. Set thresholds. Don't let regressions ship.

The companies that will win agent traffic aren't just the ones that allow agent signups — they're the ones that guarantee agent signups work at scale. Test it, measure it, and keep it working.


Want to know your baseline agent readiness before load testing? Run the free AgentGate Benchmark on your domain — it checks signup accessibility, CAPTCHA presence, OAuth availability, and 4 other categories in under 60 seconds.

Free Tool

How agent-ready is your website?

Run a free scan to see how AI agents experience your signup flow, robots.txt, API docs, and LLM visibility.

Run a free scan →