A joint controller that is supposed to run at 1 kHz almost never runs at exactly 1 kHz. Every call to your control function actually fires somewhere between 0.9 ms and 1.3 ms after the last one, depending on what the operating system, the network stack, or a garbage collector decided to do in between. That variation is called jitter, and it is one of the most common real-time considerations in robot control loops that gets glossed over until a joint starts buzzing or a gripper misses a grasp by a few milliseconds. This article gives you the vocabulary, a way to measure jitter with real code, and a formula for how much jitter your control gains can actually tolerate before things go unstable.
What jitter actually is (and what it is not)
Jitter is not the same as latency. Latency is how long it takes from a sensor sample to an actuator command going out; jitter is how much that timing varies from cycle to cycle. A loop with 2 ms of constant, predictable latency is often fine. A loop with an average period of 1 ms but individual cycles ranging from 0.6 ms to 2.4 ms is the one that will bite you, because your controller was designed assuming a fixed timestep.
Concretely, define the target period as T and the actual measured period of cycle i as T_i. Jitter for that cycle is:
jitter_i = T_i - T
You care about two numbers from a run of many cycles: the maximum absolute jitter (your worst-case deadline miss) and the standard deviation of jitter (how consistently bad it is). A control loop with max jitter under 5% of T is usually fine for position control. Torque control loops running at several kHz are far less forgiving, because a missed deadline there shows up directly as a torque discontinuity at the joint.
Measuring jitter with a real timer
Do not trust a busy while loop with a fixed sleep() call to give you a fixed period; sleep() only guarantees you sleep at least that long, not exactly that long. On Linux, the standard pattern is to track an absolute wake time and sleep until it, using clock_nanosleep with TIMER_ABSTIME rather than a relative sleep:
struct timespec next;
clock_gettime(CLOCK_MONOTONIC, &next);
const long period_ns = 1000000; // 1 kHz target
while (running) {
next.tv_nsec += period_ns;
if (next.tv_nsec >= 1000000000L) {
next.tv_nsec -= 1000000000L;
next.tv_sec += 1;
}
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, NULL);
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
long actual_period_ns = (now.tv_sec - prev.tv_sec) * 1000000000L
+ (now.tv_nsec - prev.tv_nsec);
long jitter_ns = actual_period_ns - period_ns;
log_jitter(jitter_ns);
prev = now;
run_control_step();
}
Two mistakes will corrupt this measurement before you even get to gain tuning. First, calling anything that allocates memory, locks a mutex another thread might hold, or writes a log line synchronously inside the loop; a single malloc call can occasionally take 50 to 100 microseconds due to page faults, which is enormous next to a 1 ms budget. Second, running on a stock (non-PREEMPT_RT) Linux kernel and expecting consistent sub-millisecond timing under load; the default kernel scheduler is throughput-optimized, not latency-optimized, and will happily let a control thread wait several milliseconds behind a kernel task.
Getting from a soft loop to a real-time one
You have three practical levers, roughly in order of effort:
- Thread priority. On Linux, set the control thread to
SCHED_FIFOwith a high priority viapthread_setschedparam. This alone reduces jitter significantly because the scheduler will preempt normal-priority work to run your loop. - A PREEMPT_RT kernel. The mainline PREEMPT_RT patch makes almost the entire kernel preemptible, including most interrupt handlers, which cuts worst-case latency from tens of milliseconds down to tens of microseconds on typical hardware. Most ROS2 real-time setups on an industrial PC or a Raspberry Pi CM4 use this.
- Move to an MCU-side hard-real-time loop. For torque or current loops above a few kHz, put the innermost loop on a microcontroller with a hardware timer interrupt, and let the non-real-time host (running ROS2, planning, perception) send setpoints over a serial or CAN link at a slower, jitter-tolerant rate. This is the same cascaded-loop structure described in our torque control article: fast, deterministic inner loop on dedicated hardware; slower, jitter-tolerant outer loop on general-purpose compute.
If you are already running ROS2, the executor and callback group choice also affects jitter directly, since a slow callback sharing a thread with your control callback will delay it; see our ROS2 executors and callback groups article for how to isolate a timing-critical callback onto its own thread.
How much jitter can your gains actually survive?
This is the part most articles skip: connecting a timing number to a control-stability number. A discrete PD controller implicitly assumes a fixed sample time T when you convert a continuous gain to its discrete form, particularly for the derivative term:
u_k = Kp * e_k + Kd * (e_k - e_k-1) / T
If the actual sample time on a given cycle is T + jitter instead of T, the effective derivative gain for that cycle becomes Kd / (T + jitter), not Kd / T. A cycle that runs short spikes your effective derivative gain, injecting noise-amplified torque; a cycle that runs long under-damps that step. As a rough, checkable rule for a well-tuned joint loop, keep worst-case jitter under about 10% of T:
max jitter_i < 0.1 * T
At 10% jitter on a 1 ms loop, your effective Kd varies by roughly plus or minus 10%, which most tuned PD or PID loops tolerate without visible symptoms. Push past 20 to 30% jitter and you will typically see one of two failure modes: a high-gain joint starts to buzz audibly at a frequency related to your jitter pattern, or a lower-gain joint feels mushy and lags target position more than the gains alone would predict. If you see either symptom and your average loop rate looks fine on paper, log jitter before you touch a single gain, because retuning a controller to compensate for bad timing just narrows your stable operating range instead of fixing the actual problem.
A practical checklist
- Log actual cycle period, not just the target period, for at least a few thousand cycles under real load.
- Compute max jitter and jitter standard deviation, not just the mean loop rate.
- Keep memory allocation, blocking I/O, and logging out of the control thread itself; hand results to a separate lower-priority thread via a lock-free queue.
- Match loop rate to control tier: a few kHz for current/torque loops, hundreds of Hz for velocity, tens to low hundreds of Hz for outer position or trajectory loops, since outer loops are far more jitter-tolerant.
- If jitter exceeds roughly 10% of your period on a tuned joint, fix the timing first (priority, kernel, thread isolation) before retuning gains.
Real-time considerations in robot control loops are ultimately a measurement problem before they are a tuning problem. A loop that looks fine on an oscilloscope trace of average rate can still be quietly eating into your stability margin every time a cycle runs long. Measure jitter directly, put a number on how much your gains can absorb, and you will spend far less time chasing phantom instability that was actually a scheduler problem all along.
Building or studying robotics?
Arcbotix publishes practical guides on robotics engineering, control systems and real-world builds. Explore more articles on the blog.