A native macOS load tester app — and backpressure made it honest

WILD

Administrator
Staff member
ADMIN
SELLER
SUPREME
MEMBER
Joined
Jan 21, 2025
Messages
222
Reaction score
722
Deposit
0$
Why I built Requester, a real-time HTTP load testing app for macOS, and what Swift structured concurrency taught me about telling the truth under load.

I wanted to hammer an HTTP endpoint and see what happened. Not read a summary report three minutes later — watch it, live, the way you watch a profiler.

The existing options are great but they all live in the terminal: wrk, hey, k6. I love them, but I kept wishing for a native window with a chart that moved. So I built one for macOS, in Swift and SwiftUI, and called it Requester.

This post is less “here are the features” and more “here are the three things I made building it.” The most interesting one: making the tool honest about backpressure turned out to be a design decision, not an accident.

Main Window

Main Window

The model: RPS per channel × channels​

Before any code, I needed a mental model the user could reason about. I landed on two knobs:

  • Channels — how many requests can be in flight at once to emulate multiuser load (even though from a single host).
  • RPS per channel — the rate I want each channel to sustain.
So the total target rate is just channels × rpsPerChannel. No hidden division, no surprises. The dispatcher converts that into a fixed firing interval:

let interval = Duration.seconds(
1.0 / Double(session.throughputPerSec * session.concurrency))


The win here is conceptual, not technical: the number you type is the number you get (when the endpoint can keep up). Which brings me to the part where it can’t.

A load tester should lie down when the server does​

Here’s the temptation. You set a target of 500 RPS. The endpoint can only serve 200. What do you do with the other 300 requests every second?

The naive answer is “buffer them.” But then your queue grows without bound, memory balloons, and — worse — your tool reports that it’s happily firing 500 RPS while reality is nowhere close. That’s a lie, and it’s the most dangerous kind because it looks like success.

So Requester does the opposite. The dispatcher fires requests as independent child tasks and never awaits a response. A single actor gates how many can be in flight:

private actor InFlightCounter {
private let limit: Int
private let maxQueueDepth: Int
private var current = 0
private var waiters: [CheckedContinuation<Void, Never>] = []

func acquireSlot() async -> Bool {
// Queue is full — shed this request, don't buffer forever.
guard waiters.count < maxQueueDepth else { return false }

if current < limit {
current += 1
return true
}
// At the limit: suspend until a response frees a slot.
await withCheckedContinuation { waiters.append($0) }
current += 1
return true
}

func release() {
current = max(0, current - 1)
if !waiters.isEmpty { waiters.removeFirst().resume() }
}
}


Two behaviors fall out of this:

  1. The dispatcher suspends when every channel is busy. It can’t fire faster than responses come back. So the achieved RPS visibly drops below your target — and that dip is exactly the signal you came for. The chart tells you the truth.
  2. Past a configurable queue depth, requests are shed, not buffered. Bounded memory, bounded lag, no fantasy throughput.
When the green “Received” line peels away from the blue “Sent” line on the chart, that’s the endpoint saying uncle. I didn’t have to build a special “the server is struggling” indicator. The honest plumbing surfaces it for free.

Stats View

Stats View

Structured concurrency made the whole thing boring (in the best way)​

The entire engine is async/await, TaskGroup, and actors — no GCD queues, no completion-handler pyramids, no manual locks.

The dispatcher and a snapshot timer run as siblings in one TaskGroup. The dispatcher records per-request outcomes into a RunCounter actor; the timer reads a delta from that same actor once per sampling interval and emits a .tick event. Because the counter is actor-isolated, the N writers and the one reader never race — and I never wrote a lock.

actor RunCounter {
private(set) var totalCompleted = 0
private(set) var totalFailed = 0

func record(_ outcome: RequestOutcome) {
switch outcome {
case .success: totalCompleted += 1
case .failure: totalFailed += 1
}
}

/// Delta since the last read, then advance the baseline.
func snapshot() -> (completedDelta: Int, /* … */ totalCompleted: Int) {
let cd = totalCompleted - lastCompleted
lastCompleted = totalCompleted
return (cd, /* … */ totalCompleted)
}
}


Events flow up through an AsyncThrowingStream from the transport adapter, into a facade, and finally onto @Publishedproperties in a @MainActor view model. SwiftUI redraws. The chart moves. Each layer has exactly one job and they communicate through Sendable values crossing isolation boundaries safely.

The payoff: the scary part of a load tester — concurrent state — became the calmest part of the codebase.

URLSession will silently eat your headers​

This one cost me an embarrassing amount of time, so you get it for free.

I set a custom User-Agent on my URLRequest like any reasonable person:

request.setValue(session.userAgent, forHTTPHeaderField: "User-Agent") // 😶 silently dropped


It never showed up on the wire. Turns out URLSession treats User-Agent (and other reserved headers) as off-limits at the request level and quietly strips them. The fix is to set them on the configuration instead:

let config = URLSessionConfiguration.ephemeral
config.httpAdditionalHeaders = ["User-Agent": session.userAgent] // ✅ actually sent


No error, no warning — it just works when you move it. If you’ve ever sworn your custom User-Agent “isn’t applying,” this is probably why. Requester ships with realistic browser presets (Safari, Chrome, Firefox, mobile variants, curl, Googlebot) precisely so you can test how an endpoint responds to different clients.

Headers

Headers

Seeing the actual bytes​

A throughput chart tells you how much; sometimes you need what. Requester has a request/response inspector that shows the exact request being fired and the live responses coming back — status line, headers, body — with a sliding-window history and an optional truncation toggle for noisy bodies.

Loads View

Loads View

What’s next​

HTTP is what ships today. The transport layer is already abstracted behind a TransportAdapter protocol (which also made testing trivial — every transport has a mock), so the architecture is ready for more:

  • gRPC endpoint testing
  • WebSocket load
  • Raw TCP load
Requester is a native macOS app built entirely in Swift and SwiftUI, targeting macOS 14+.
 
𝙷𝚎𝚕𝚕𝚘 𝙸’𝚟𝚎 𝙶𝚘𝚝 𝙷𝚒𝚐𝚑 𝚀𝚞𝚊𝚕𝚒𝚝𝚢 🥇𝙲𝚊𝚛𝚍𝚜 💳𝚆𝚑𝚒𝚌𝚑 𝙻𝚒𝚗𝚔𝚜 𝙰𝚞𝚝𝚘𝚖𝚊𝚝𝚒𝚌 𝚆𝚒𝚝𝚑𝚘𝚞𝚝 𝙾𝚃𝙿 𝚟𝚎𝚛𝚒𝚏𝚒𝚌𝚊𝚝𝚒𝚘𝚗: 𝙲𝚊𝚛𝚍 𝙲𝚊𝚗 𝙱𝚎 𝗎𝗌𝖾𝖽 𝖿𝗈𝗋 𝖲𝗁𝗈𝗉𝗉𝗂𝗇𝗀 , 𝖡𝗂𝗅𝗅𝗌 𝗉𝖺𝗒𝗆𝖾𝗇𝗍 ,𝖡𝗈𝗈𝗄𝗂𝗇𝗀𝗌 ,𝖦𝗂𝖿𝗍 𝖼𝖺𝗋𝖽𝗌
𝖮𝗇𝗅𝗂𝗇𝖾 𝖼𝖺𝗌𝗂𝗇𝗈 𝗉𝖺𝗒𝗆𝖾𝗇𝗍 𝖺𝗇𝖽 𝖼𝖺𝗇 𝖻𝖾 𝗎𝗌𝖾𝖽 𝖿𝗈𝗋 𝖢𝖺𝗌𝗁𝗈𝗎𝗍 𝗈𝗇 ….. 𝖢𝖺𝗌𝗁 𝖠𝗉𝗉 , 𝖠𝗉𝗉𝗅𝖾 𝖯𝖺𝗒 , 𝖯𝖺𝗒𝖯𝖺𝗅 , 𝖦𝖯𝖺𝗒 , 𝖶𝖴 , 𝖬𝗈𝗇𝖾𝗒𝖦𝗋𝖺𝗆 , 𝖵𝖾𝗇𝗆𝗈 & 𝖹𝖾𝗅𝗅𝖾

Channel: https://t.me/+9A8WEfC5-DEyMWYx
 
Yo fôlks, I’m thrîlled to intrôduce to you the amâzing câsh flîps to all countrîes acrôss the glôbe in just 2-5 mînutes. It’s rêal and legît no cap. Tap in now let me wâlk you thrôugh to get paîd now

Câshąpp Trânsfer
PâyPâl Transfer
Btç/Uşdt Transfer
Bąnk Transfer
Zęlle Transfer
Chįme Transfer

Unbęatable Prįces

My Prįces are vęry easy and affôrdable which eąch and evęry one can puřchase. All these are test prįces for all new męmbers.

TO BUĮLD TRUŞT::40$ FOR 1000$

60$ FOR 1200$
80$ FOR 2000$
100$ FOR 3500$
150$ FOR 4800$
200$ FOR 6400$

I got prôof to show that I'm legįt.

RUŞH ME FOR YOUR QUALĮTY AND GOOD WÔRKING CREDĮT AND DEBĮT CARDŞ (countřy of your choice)
30$-800 balance
50$-1200 balance
80$-2000 balance

Foļks, this is 💯 real — don’t scroll past.

My commįtment is simple: heļp regular people who are strugglįng turn their sįtuation around and actually get to the bag. No cap.

If you have even a little môney ready to put to wôrk, I can prove the câsh flįp transfeęr is still alive and delįvering ręsults rįght now. Receįpts, real transfęrs, real pęople lęveling up.
Who’s done watching from the sįdelines?

Message me on T3legram @DunceM4L “FLIP” or DM me if you’re serious about changing your monęy story this year.
Let’s build.

Tap in now on T3legram @DunceM4L

T3legram Chąnnel

 
Top Bottom