Chapter 07Lesson 02~200 minutes

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.

Constant TimerUniform RandomCTTPTTTimeline

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.
Current lab baseline: Apache JMeter 5.6.3 with a Java 17 JDK; JMeter 5.6.3 requires Java 8+. No third-party plugins are required.

1. Safety envelope

Target only 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?

Why can PTT target 1 scenario start/s while total HTTP RPS is about 2/s?

Why can one thread not sustain 3 scenarios/s in a 0.60 s journey?

Does PTT Test duration stop the Thread Group?

How do you separate timer delay from target latency?

Next lesson

Choose timing components from the workload model

Lesson 3 turns the experiments into design rules for user pauses, pacing, rate schedulers, seeds, scope, and optional open arrivals.

Official references and version notes

Version and compatibility note

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.