Skip to main content
Back to blog
performance 21 February 2023 6 min read

Performance Testing Microservices: Isolation and Dependencies

Strategies for performance testing microservices architectures, including service isolation, dependency mocking, and distributed tracing.

M

Mark

Performance Testing Expert

Performance testing microservices is fundamentally different from testing monolithic applications. Instead of a single system with predictable behaviour, you’re testing a network of interdependent services where performance issues can cascade unpredictably.

The Microservices Testing Challenge

A typical request might traverse multiple services:

Client → API Gateway → Auth Service → Product Service → Inventory Service → Database

                              Pricing Service → Cache

Each hop adds latency, and any service can become a bottleneck. Testing requires understanding both individual service performance and system-wide behaviour.

Service Isolation Testing

Start by testing each service in isolation to establish baselines:

// k6 test for Product Service in isolation
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  scenarios: {
    product_service: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 50 },
        { duration: '5m', target: 50 },
        { duration: '2m', target: 0 },
      ],
    },
  },
  thresholds: {
    http_req_duration: ['p(95)<100'],  // Service-specific SLA
  },
};

const PRODUCT_SERVICE = __ENV.PRODUCT_SERVICE_URL;

export default function () {
  const productId = Math.floor(Math.random() * 1000) + 1;

  const res = http.get(`${PRODUCT_SERVICE}/products/${productId}`);

  check(res, {
    'status is 200': (r) => r.status === 200,
    'has product data': (r) => r.json().id !== undefined,
  });
}

Document baseline metrics for each service:

Servicep50p95p99Max RPS
Auth15ms45ms80ms5000
Product25ms60ms120ms3000
Inventory20ms50ms100ms4000
Pricing10ms30ms60ms8000

Mocking Dependencies

For isolated testing, mock downstream services:

# docker-compose.yml for isolated testing
version: '3'
services:
  product-service:
    build: ./product-service
    environment:
      - INVENTORY_URL=http://mock-inventory:8080
      - PRICING_URL=http://mock-pricing:8080

  mock-inventory:
    image: wiremock/wiremock
    volumes:
      - ./mocks/inventory:/home/wiremock

  mock-pricing:
    image: wiremock/wiremock
    volumes:
      - ./mocks/pricing:/home/wiremock

WireMock stub for inventory service:

{
  "request": {
    "method": "GET",
    "urlPathPattern": "/inventory/.*"
  },
  "response": {
    "status": 200,
    "jsonBody": {
      "available": true,
      "quantity": 100
    },
    "fixedDelayMilliseconds": 20
  }
}

The fixed delay simulates realistic downstream latency.

End-to-End Testing

After isolated testing, test the complete request path:

// End-to-end user journey test
import http from 'k6/http';
import { check, group, sleep } from 'k6';

const BASE_URL = __ENV.API_GATEWAY_URL;

export default function () {
  let token;

  group('Authentication', () => {
    const loginRes = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
      username: 'testuser',
      password: 'testpass',
    }), { headers: { 'Content-Type': 'application/json' } });

    check(loginRes, { 'login successful': (r) => r.status === 200 });
    token = loginRes.json('token');
  });

  const headers = {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json',
  };

  group('Browse Products', () => {
    const products = http.get(`${BASE_URL}/products`, { headers });
    check(products, { 'products loaded': (r) => r.status === 200 });
  });

  group('Check Inventory', () => {
    const inventory = http.get(`${BASE_URL}/products/123/availability`, { headers });
    check(inventory, { 'inventory checked': (r) => r.status === 200 });
  });

  group('Create Order', () => {
    const order = http.post(`${BASE_URL}/orders`, JSON.stringify({
      productId: 123,
      quantity: 1,
    }), { headers });
    check(order, { 'order created': (r) => r.status === 201 });
  });

  sleep(1);
}

Distributed Tracing

Correlate performance data across services using distributed tracing:

import http from 'k6/http';
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';

export default function () {
  const traceId = uuidv4();

  const headers = {
    'X-Trace-ID': traceId,
    'X-Span-ID': uuidv4(),
  };

  console.log(`Trace ID: ${traceId}`);

  const res = http.get(`${__ENV.API_URL}/products/123`, { headers });

  // Log trace ID with response time for correlation
  console.log(`${traceId}: ${res.timings.duration}ms`);
}

Query your tracing system (Jaeger, Zipkin) with the trace ID to see where time was spent:

Auth Service:     ████░░░░░░░░░░░░░░░░  45ms
Product Service:  ░░░░████████░░░░░░░░  120ms
Inventory Check:  ░░░░░░░░░░░░████░░░░  60ms
Pricing Lookup:   ░░░░░░░░░░░░░░░░██░░  30ms
Total:            ████████████████████  255ms

Circuit Breaker Testing

Test how services behave when dependencies fail:

// Test circuit breaker behaviour
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  scenarios: {
    normal_load: {
      executor: 'constant-vus',
      vus: 20,
      duration: '2m',
    },
    dependency_failure: {
      executor: 'constant-vus',
      vus: 20,
      duration: '2m',
      startTime: '2m',  // Start after normal load
      env: { FAIL_INVENTORY: 'true' },
    },
    recovery: {
      executor: 'constant-vus',
      vus: 20,
      duration: '2m',
      startTime: '4m',  // After dependency restored
    },
  },
};

Verify:

  • Response times don’t spike dramatically when dependencies fail
  • Error responses are returned quickly (circuit open)
  • Service recovers when dependency returns

Load Distribution Patterns

Microservices often have uneven load distribution. Test realistic patterns:

export const options = {
  scenarios: {
    read_heavy: {
      executor: 'constant-vus',
      vus: 80,
      duration: '10m',
      exec: 'readOperations',
    },
    write_light: {
      executor: 'constant-vus',
      vus: 20,
      duration: '10m',
      exec: 'writeOperations',
    },
  },
};

export function readOperations() {
  http.get(`${BASE_URL}/products`);
}

export function writeOperations() {
  http.post(`${BASE_URL}/orders`, JSON.stringify({ productId: 1 }));
}

Latency Aggregation

In microservices, latencies compound. A request touching 5 services, each with 50ms p95, won’t have a 250ms p95 for the total request - it will be higher due to variance:

// Measure and report per-service contribution
import http from 'k6/http';
import { Trend } from 'k6/metrics';

const authLatency = new Trend('auth_latency');
const productLatency = new Trend('product_latency');
const inventoryLatency = new Trend('inventory_latency');
const totalLatency = new Trend('total_latency');

export default function () {
  const start = Date.now();

  const auth = http.post(`${BASE_URL}/auth/token`);
  authLatency.add(auth.timings.duration);

  const product = http.get(`${BASE_URL}/products/123`);
  productLatency.add(product.timings.duration);

  const inventory = http.get(`${BASE_URL}/inventory/123`);
  inventoryLatency.add(inventory.timings.duration);

  totalLatency.add(Date.now() - start);
}

Database Per Service

When each service has its own database, test database performance in context:

// Test service with realistic database load
export const options = {
  scenarios: {
    mixed_queries: {
      executor: 'ramping-vus',
      stages: [
        { duration: '5m', target: 100 },
        { duration: '10m', target: 100 },
        { duration: '5m', target: 0 },
      ],
    },
  },
};

export default function () {
  // Simple read (cached)
  http.get(`${BASE_URL}/products/popular`);

  // Complex query (database hit)
  http.get(`${BASE_URL}/products/search?q=laptop&sort=price&filter=instock`);

  // Write operation
  if (Math.random() < 0.1) {  // 10% writes
    http.post(`${BASE_URL}/products/123/reviews`, JSON.stringify({
      rating: 5,
      comment: 'Great product',
    }));
  }
}

Recommendations

  1. Test services in isolation first to establish baselines
  2. Mock dependencies with realistic latency for isolated tests
  3. Use distributed tracing to identify bottlenecks across services
  4. Test failure scenarios - circuit breakers, timeouts, retries
  5. Monitor all services during end-to-end tests
  6. Document SLAs for each service and the system as a whole

Microservices performance testing requires more planning than monolith testing, but provides better insight into system behaviour and helps identify issues before they cascade into outages.

Tags:

#microservices #distributed-systems #performance-testing

Need help with performance testing?

Let's discuss how I can help improve your application's performance.

Get in Touch