Webhook Delivery Tutorial · Module 06 of 10

Performance & Batching

Can you deliver 1000+ events/sec? Optimize: batch database inserts, run multiple workers, profile bottlenecks, and load test. By the end, you'll have a high-throughput system with measured performance.

~4–5 hrsIntermediatePerformance focus
← Back to Module 06 overview
What You'll Have at the End

Definition of Done

  • Batch insertion of delivery jobs (not one-by-one).
  • Multiple worker processes running in parallel.
  • Load test with k6 or Locust: measure throughput, p50, p95, p99.
  • Achieve 1000+ events/sec to 100 webhooks on local hardware.
  • Scaling test: adding 2nd worker roughly doubles throughput.
  • Performance report committed to /docs/perf.
The Steps

Build It

STEP 1

Optimize POST /events to batch enqueue deliveries

Update src/routes/events.ts:

// Instead of one-by-one inserts:
const batchDeliveries = webhooksResult.rows.map(webhook => {
  const deliveryId = crypto.randomUUID();
  return [deliveryId, webhook.id, eventRow.id, 'pending'];
});

// Batch insert
if (batchDeliveries.length > 0) {
  const values = batchDeliveries.map((_, i) =>
    `($${i*4+1}, $${i*4+2}, $${i*4+3}, $${i*4+4})`
  ).join(',');

  const flatValues = batchDeliveries.flat();
  await pool.query(
    `INSERT INTO deliveries (id, webhook_id, event_id, status) VALUES ${values}`,
    flatValues
  );
}

// Batch enqueue jobs
const jobs = webhooksResult.rows.map(webhook => ({
  delivery_id: generateId(),
  webhook_id: webhook.id,
  event_id: eventRow.id,
  webhook_url: webhook.url,
  webhook_secret: webhook.secret,
  payload: payload,
  attempt: 0
}));

await deliveryQueue.addBulk(jobs.map(job => ({ data: job })));
✓ Verify: POST /events completes in <50ms for event with 100 webhooks.
STEP 2

Run multiple worker processes

Create src/workers.ts to manage multiple workers:

import './worker/deliveryWorker';

const NUM_WORKERS = parseInt(process.env.NUM_WORKERS || '1');

console.log(`Starting ${NUM_WORKERS} delivery workers...`);

for (let i = 0; i < NUM_WORKERS; i++) {
  console.log(`Worker ${i+1} started`);
}

Or use a process manager like concurrently:

npm install --save-dev concurrently

"scripts": {
  "worker:multi": "concurrently 'npm run worker' 'npm run worker' 'npm run worker'"
}
✓ Verify: Run npm run worker:multi and see 3 workers start.
STEP 3

Install k6 for load testing

npm install --save-dev k6

Create load-test.js:

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '10s', target: 100 },  // Ramp to 100 concurrent requests
    { duration: '30s', target: 100 },  // Stay at 100
    { duration: '10s', target: 0 },    // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],  // 95th percentile under 500ms
  },
};

// Create 100 webhooks first
const webhooks = [];
for (let i = 0; i < 100; i++) {
  const res = http.post('http://localhost:3000/webhooks', JSON.stringify({
    url: `https://webhook${i}.example.com/hook`,
    secret: 'secret',
    event_types: ['order.created']
  }), { headers: { 'Content-Type': 'application/json' } });
  webhooks.push(JSON.parse(res.body));
}

export default function () {
  const payload = JSON.stringify({
    type: 'order.created',
    payload: { order_id: Math.random() * 100000 }
  });

  const res = http.post('http://localhost:3000/events', payload, {
    headers: { 'Content-Type': 'application/json' },
  });

  check(res, {
    'status is 201': (r) => r.status === 201,
    'response time < 500ms': (r) => r.timings.duration < 500,
  });

  sleep(0.1);
}
✓ Verify: k6 run load-test.js completes with results.
STEP 4

Profile bottlenecks

Add timing logs to src/routes/events.ts:

const t1 = Date.now();

// Webhook lookup
const webhooksResult = await pool.query(...);
const t2 = Date.now();

// Batch insert deliveries
await pool.query(...);
const t3 = Date.now();

// Enqueue jobs
await deliveryQueue.addBulk(...);
const t4 = Date.now();

console.log(`[PERF] webhook_lookup=${t2-t1}ms, batch_insert=${t3-t2}ms, enqueue=${t4-t3}ms`);

Run load test and check logs to identify slowest operation.

✓ Verify: Logs show timing breakdown. Identify which step is slowest.
STEP 5

Measure with multiple workers

Run load test with 1, 2, 3 workers:

NUM_WORKERS=1 npm run dev & npm run worker
# Wait for load test to complete
# Record results

NUM_WORKERS=2 npm run dev & npm run worker:multi
# Run same load test
# Record results and compare
✓ Verify: 2 workers roughly double throughput compared to 1.
STEP 6

Document performance results

Create docs/perf/load-test-report.md:

# Load Test Report

## Test Setup
- Webhooks: 100
- Events: 5000 total (100 VUs × 50 requests each)
- Duration: 50 seconds
- Workers: 1, 2, 3

## Results
| Workers | Throughput (req/s) | p50 (ms) | p95 (ms) | p99 (ms) |
|---------|-------------------|----------|----------|----------|
| 1       | 98 req/s           | 450      | 850      | 1200     |
| 2       | 195 req/s          | 430      | 780      | 1050     |
| 3       | 287 req/s          | 410      | 720      | 950      |

## Conclusion
Throughput scales near-linearly with worker count. Each worker handles ~100 req/s on this hardware.
✓ Verify: Report is committed and shows performance improvements with batching and multiple workers.
STEP 7

Commit performance work

git add -A
git commit -m "perf: optimize delivery throughput with batching and workers

- Batch enqueue delivery jobs (not one-by-one)
- Support multiple worker processes
- k6 load test: 1000+ events/sec with 100 webhooks
- Performance report with scaling metrics
- 2-3 workers scale throughput near-linearly"
git push origin main
✓ Verify: git log --oneline shows your commit.
Next Steps

Ready for Module 07?

You now have a fast, scalable system. Next, you'll instrument it: logs, metrics, traces, and dashboards. Head to Module 07.