Timers, Think Time, Pacing, Throughput, and Realistic User Behavior: Guided Hands-On Workflow
Use one loopback service and change one timing factor at a time. The goal is to see Timer waiting in the timeline while endpoint elapsed remains separately measurable.
Learning objectives
- Run a bounded synthetic timing service.
- Compare tight-loop, Constant, and Uniform Random waits.
- Exercise Constant and Precise Throughput Timers conservatively.
- Calculate thread supply before rate tests.
- Demonstrate a configured rate miss caused by insufficient concurrency.
-
Preserve JTL,
jmeter.log, server timeline, and generator evidence.
1. Safety envelope
http://127.0.0.1:8000. ≤4
threads, ≤12 seconds, synthetic data, no external service. Abort on
target mismatch, unexpected errors, or unsafe generator pressure.
2. Local timing fixture
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
from pathlib import Path
import argparse, json, threading, time
lock=threading.Lock(); active=0; max_active=0; total=0; by_path={}; event_log=None
DELAYS={'/browse':80,'/submit':120,'/slow':700}
def rec(e):
if event_log:
with lock:
with event_log.open('a',encoding='utf-8') as f: f.write(json.dumps(e,sort_keys=True)+'\\n')
class H(BaseHTTPRequestHandler):
protocol_version='HTTP/1.1'
def js(self,status,p):
b=json.dumps(p,sort_keys=True).encode(); self.send_response(status); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
def handle_work(self):
global active,max_active,total
path=urlparse(self.path).path
if path=='/health': self.js(200,{'status':'ok'}); return
if path=='/stats':
with lock: s={'active':active,'max_active':max_active,'total':total,'by_path':dict(by_path)}
self.js(200,s); return
if path not in DELAYS: self.js(404,{'error':'not_found','path':path}); return
with lock:
total+=1; active+=1; max_active=max(max_active,active); by_path[path]=by_path.get(path,0)+1; no=total; a=active
st=int(time.time()*1000)
try:
time.sleep(DELAYS[path]/1000); self.js(200,{'status':'ok','path':path,'request_no':no,'service_delay_ms':DELAYS[path],'active_at_start':a})
finally:
en=int(time.time()*1000); rec({'path':path,'request_no':no,'started_ms':st,'finished_ms':en,'active_at_start':a,'service_delay_ms':DELAYS[path]})
with lock: active-=1
def do_GET(self): self.handle_work()
def do_POST(self):
n=int(self.headers.get('Content-Length','0') or '0')
if n>4096: self.js(413,{'error':'payload_too_large'}); return
if n: self.rfile.read(n)
self.handle_work()
def log_message(self,*args): return
if __name__=='__main__':
p=argparse.ArgumentParser(); p.add_argument('--log',default='results/server-events.jsonl'); a=p.parse_args(); event_log=Path(a.log).resolve(); event_log.parent.mkdir(parents=True,exist_ok=True); event_log.write_text('',encoding='utf-8'); print('fixture=http://127.0.0.1:8000'); print(f'event_log={event_log}'); ThreadingHTTPServer(('127.0.0.1',8000),H).serve_forever()
Start it:
python fixtures/timing_fixture.py --log results/server-events.jsonl
curl --fail --silent http://127.0.0.1:8000/health
curl --fail --silent http://127.0.0.1:8000/stats
3. JTL analyzer and timeline
import csv, math, sys
from collections import defaultdict
from pathlib import Path
p=Path(sys.argv[1] if len(sys.argv)>1 else 'results/run/results.jtl'); rows=list(csv.DictReader(p.open(encoding='utf-8')))
if not rows: raise SystemExit('No samples')
need={'timeStamp','elapsed','label','success','grpThreads','allThreads'}; miss=need.difference(rows[0]);
if miss: raise SystemExit(f'Missing columns: {sorted(miss)}')
def start(r): return int(r['timeStamp'])-int(r['elapsed'])
def pct(v,p):
v=sorted(v); return v[max(1,math.ceil(len(v)*p/100))-1]
span=max((max(int(r['timeStamp']) for r in rows)-min(start(r) for r in rows))/1000,0.001)
print(f'samples={len(rows)}'); print(f'failures={sum(r["success"].lower()!="true" for r in rows)}'); print(f'overall_sample_rps={len(rows)/span:.3f}'); print(f'max_grpThreads_seen={max(int(r["grpThreads"]) for r in rows)}'); print(f'max_allThreads_seen={max(int(r["allThreads"]) for r in rows)}')
g=defaultdict(list)
for r in rows:g[r['label']].append(r)
for label,items in sorted(g.items()):
el=[int(r['elapsed']) for r in items]; s=[start(r) for r in items]; e=[int(r['timeStamp']) for r in items]; sp=max((max(e)-min(s))/1000,0.001); print(f'label={label!r} count={len(items)} observed_rps={len(items)/sp:.3f} p50_ms={pct(el,50)} p95_ms={pct(el,95)}')
print('timeline:')
for r in sorted(rows,key=start): print(f' start_ms={start(r)} end_ms={r["timeStamp"]} label={r["label"]} elapsed_ms={r["elapsed"]} grpThreads={r["grpThreads"]} allThreads={r["allThreads"]}')
The script reconstructs sample start as
timeStamp - elapsed using the default end-timestamp
convention. Timer wait is visible as gaps between sample intervals,
not inside elapsed.
4. Experiment A — tight loop
1 thread × 6 loops, Browse /browse, no Timer. Predict
six ~80 ms samples with minimal inter-sample waiting and a
machine-like achieved rate.
5. Experiment B — 400 ms Constant Timer
Attach a 400 ms Constant Timer directly under Browse. A syntactically checkable core JMX is shown below.
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3"><hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="Chapter 07 Constant Timer Baseline" enabled="true"><boolProp name="TestPlan.functional_mode">false</boolProp><boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp><boolProp name="TestPlan.serialize_threadgroups">false</boolProp><elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables"><collectionProp name="Arguments.arguments"/></elementProp><stringProp name="TestPlan.user_define_classpath"></stringProp></TestPlan><hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="1 user x 6 loops" enabled="true"><stringProp name="ThreadGroup.on_sample_error">continue</stringProp><elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller"><boolProp name="LoopController.continue_forever">false</boolProp><stringProp name="LoopController.loops">6</stringProp></elementProp><stringProp name="ThreadGroup.num_threads">1</stringProp><stringProp name="ThreadGroup.ramp_time">1</stringProp><boolProp name="ThreadGroup.scheduler">false</boolProp><boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp></ThreadGroup><hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Browse" enabled="true"><elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="User Defined Variables"><collectionProp name="Arguments.arguments"/></elementProp><stringProp name="HTTPSampler.domain">127.0.0.1</stringProp><stringProp name="HTTPSampler.port">8000</stringProp><stringProp name="HTTPSampler.protocol">http</stringProp><stringProp name="HTTPSampler.path">/browse</stringProp><stringProp name="HTTPSampler.method">GET</stringProp><boolProp name="HTTPSampler.follow_redirects">true</boolProp><boolProp name="HTTPSampler.use_keepalive">true</boolProp><stringProp name="HTTPSampler.connect_timeout">1000</stringProp><stringProp name="HTTPSampler.response_timeout">2000</stringProp></HTTPSamplerProxy><hashTree><ConstantTimer guiclass="ConstantTimerGui" testclass="ConstantTimer" testname="Think before Browse - 400 ms" enabled="true"><stringProp name="ConstantTimer.delay">400</stringProp></ConstantTimer><hashTree/></hashTree>
</hashTree></hashTree></hashTree></jmeterTestPlan>
Predict similar Browse p50/p95 but start gaps about 400 ms longer.
6. Experiment C — Uniform Random Timer
Replace Constant Timer with Uniform Random Timer: Constant Delay Offset 300 ms, Random Delay Maximum 300 ms. Run 1 thread × 8 loops. Predict ~300–600 ms variable start gaps while server elapsed remains ~80 ms.
7. Experiment D — Constant Throughput Timer
Use 2 threads, 10-second duration, one Browse sampler and no user think timer. Configure Constant Throughput Timer at 60 samples/minute with all active threads in current thread group (shared). Measure whether Browse approaches ~1/s; never treat the setting itself as evidence.
8. Experiment E — PTT schedules scenario starts
Thread Group — 3 users, ramp 0, duration 10 s
├── Browse — GET /browse
│ └── Precise Throughput Timer
│ Target: 10 samples / 10 s
│ Test duration: 10 s
│ Batch: 1, delay 0, seed 4242
└── Submit — POST /submit
└── Constant Timer — 400 ms
PTT is scoped only to Browse, so the controlled population is ~1 Browse/scenario start per second. Each completed scenario also generates Submit; total HTTP RPS is therefore a different metric.
9. Concurrency math
Browse service ≈ 0.080 s
Think before Submit = 0.400 s
Submit service ≈ 0.120 s
Mean occupancy ≈ 0.600 s
N ≈ 1/s × 0.60 s = 0.60 → at least 1; use 3 as modest schedule-serving headroom
10. Experiment F — intentionally under-supplied
Keep the same journey but use 1 thread, 8-second duration, and PTT
target 24 Browse starts / 8 s = 3/s. One thread can complete only
roughly 1/0.60 ≈ 1.67 scenarios/s before overhead, so a
3/s schedule is impossible. Expected result: achieved Browse-start
RPS is materially below configured rate even though server p50/p95
can remain healthy.
11. Slow-target comparison
Replace Browse with /slow (~700 ms) while preserving
400 ms think and 120 ms Submit. Occupancy exceeds ~1.2 seconds and
achievable closed-loop rate falls further. This demonstrates how
target service time consumes thread supply without conflating it
with Timer wait.
12. CLI evidence contract
mkdir -p results/e-ptt
jmeter -n \
-t plans/e-ptt.jmx \
-l results/e-ptt/results.jtl \
-j results/e-ptt/jmeter.log \
-Jjmeter.save.saveservice.print_field_names=true \
-Jjmeter.save.saveservice.thread_counts=true \
-Jsampleresult.timestamp.start=false
python tools/analyze_timing.py results/e-ptt/results.jtl
PowerShell uses the same JMeter flags with
jmeter.bat and backtick line continuations.
13. Annotated timeline
PTT wait | Browse [~80 ms] | think [400 ms] | Submit [~120 ms] | next schedule...
^ sampler elapsed ^ sampler elapsed
^ generator wait is outside the previous sampler elapsed
14. Challenge
Five support agents each read for ten seconds. Should you shorten think time to one second just to hit a desired RPS? No: that falsifies user behavior. Keep the business think time and let achieved rate reveal what five users can actually produce, or choose an open-arrival model if independent arrivals are the true requirement.
Knowledge check
What should change between tight-loop and Constant-Timer runs?
Start spacing and achieved rate should change; endpoint sampler latency should remain comparable.
Why can PTT target 1 scenario start/s while total HTTP RPS is about 2/s?
Each scenario contains Browse and Submit; the Timer is scoped only to Browse.
Why can one thread not sustain 3 scenarios/s in a 0.60 s journey?
Its approximate maximum is only about 1.67 scenarios/s before overhead.
Does PTT Test duration stop the Thread Group?
No. The Thread Group needs its own bounded lifetime/loop condition.
How do you separate timer delay from target latency?
Use sampler elapsed plus reconstructed timeline and independent server-event timestamps.
Official references and version notes
- Elements of a Test Plan — timer scope, additive delays, execution order, and Thread Groups.
- Component Reference — Constant, Uniform Random, Constant Throughput, Precise Throughput, and Open Model behavior.
- Properties Reference — timer.factor and result settings.
- Best Practices — thread sizing, coordinated-omission warning, CLI execution, and lean listeners.
- Getting Started — GUI authoring versus CLI load execution.
- Download Apache JMeter — current production release and Java requirement.
Verified 2026-09-05: Apache JMeter 5.6.3; release requires Java 8+, labs use Java 17; no plugins. Timers run before in-scope samplers and applicable timers add together. Constant/Precise Throughput Timers pace existing threads and cannot guarantee configured throughput if threads, other delays, the generator, or target are limiting. Precise Throughput Timer uses a Poisson-style schedule, and its timer test-duration field is not the Thread Group stop condition. The current Open Model Thread Group is explicitly experimental and is optional only in this chapter.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.