A rate-measuring counter managed with a single variable

Published on June 19, 2026

A rate-measuring counter managed with a single variable

1. Introduction: counters vs. rates

When operating web applications and systems, simple counters are often used to measure things like the number of requests, the number of errors, or the number of processed jobs. For example, you can grasp a cumulative total easily just by adding, as in total_requests += 1.
In real-world operations, however, there are quite a few cases where not only the "cumulative value" but also the real-time "amount of change" matters. For instance, an alert saying the cumulative error count is 1,000 does not let you judge the severity. If those 1,000 errors accumulated gradually over a week it may not be a big problem, but if 1,000 errors suddenly occurred within the last 10 seconds, it is very likely a critical incident.
This article explains a technique for measuring the latter—the "amount of change (rate)"—in a lightweight way. Exponential decay is usually expressed with two variables, but the proposed method achieves it with just a single variable.

2. Conventional implementations

There are mainly two representative approaches for measuring the "amount of change (rate)."

2.1 Using buckets

An intuitive method is to prepare buckets for fixed time intervals and count the number of events within each. For example, if you want to know the "number of requests in the last minute," you keep 60 buckets, one per second.
[12, 9, 10, 13, ...]  // image of the last 60 seconds of buckets
This method is easy to understand, but because it needs to hold multiple pieces of state, memory and processing overhead grow when you widen the measurement time window or increase the total number of metrics across the system.

2.2 Using exponential decay

Another strong method is the exponential-decay idea of "giving lighter weight to older events." This technique is known as EWMA (exponentially weighted moving average) or exponential moving average, and the current value is computed with the following formula.
\[ \operatorname{value}(\mathrm{now}) = \operatorname{value}(\mathrm{last}) \times \exp\left(-\frac{\mathrm{now} - \mathrm{last}}{\mathrm{duration}}\right) \]
The meaning of each variable is as follows.
  • value(last): the value at the time of the last update
  • last: the time of the last update
  • now: the current time
  • duration: the time scale of the decay (time constant / effective window width)
If you implement this formula directly in a program, you need two variables: the "time of the last update (last_updated)" and "the value at that time (value_at_last_updated)."

3. The proposed method: managing exponential decay with a single variable

3.1 Overview

Normally, implementing an exponential-decay counter requires holding the two pieces of state above, but the proposed method manages it with just one variable: a "time-like parameter (pointer_time)."
The key point of this technique is that it does not store the current value itself; instead, it stores a parameter (pointer_time) from which the current value can be reconstructed using the current time and the decay time scale.
\[ \operatorname{value}(\mathrm{now}) = \exp\left(\frac{\mathrm{pointer\_time} - \mathrm{now}}{\mathrm{duration}}\right) \]
Note here that pointer_time is not the actual time an event occurred, but a parameter that expresses the position on the exponential-decay curve.
Intuitively, it can be interpreted as follows.
  • pointer_time is in the future relative to now: the value is large
  • pointer_time is close to now: the value is close to 1
  • pointer_time is in the past relative to now: the value has decayed
In other words, by remembering the value not as a mere "point" but as a "position on the exponential curve," the required number of variables is reduced to one.

3.2 How to count up

So how do you add to this single-variable counter? To increase the value by \(x\) at some time \(t\), you process it in the following three steps.
  1. Decode (reconstruct): compute the decayed value at the current time \(t\)
  2. Increment (add): add the increment \(x\) to the reconstructed value
  3. Encode (update): from the new value, back-calculate and store the pointer_time to be used in the next computation
In Decode, you reconstruct the current value from the stored pointer_time.
\[ \mathrm{old\_value} = \exp\left(\frac{\mathrm{pointer\_time} - t}{\mathrm{duration}}\right) \]
Next, you add the increment to the reconstructed value.
\[ \mathrm{new\_value} = \mathrm{old\_value} + x \]
Finally, you back-calculate the pointer_time that represents the new value.
\[ \mathrm{pointer\_time}_{\mathrm{new}} = t + \mathrm{duration} \times \log(\mathrm{new\_value}) \]
What is ultimately stored is only the updated pointer_time.

3.3 Why is the update time no longer needed?

It seems mysterious at first glance, but expanding the formula reveals the reason. The general exponential-decay formula is as follows.
\[ \operatorname{value}(\mathrm{now}) = \operatorname{value}(\mathrm{last}) \times \exp\left(-\frac{\mathrm{now} - \mathrm{last}}{\mathrm{duration}}\right) \]
Here, if you replace \(\operatorname{value}(\mathrm{last})\) with the proposed representation \(\exp((\mathrm{pointer\_time} - \mathrm{last}) / \mathrm{duration})\), you get the following.
\[ \begin{aligned} \operatorname{value}(\mathrm{now}) &= \exp\left(\frac{\mathrm{pointer\_time} - \mathrm{last}}{\mathrm{duration}}\right) \times \exp\left(-\frac{\mathrm{now} - \mathrm{last}}{\mathrm{duration}}\right) \\ &= \exp\left(\frac{\mathrm{pointer\_time} - \mathrm{now}}{\mathrm{duration}}\right) \end{aligned} \]
As you can see, last (the time of the last update) cancels out and disappears. In other words, the two pieces of information—"the time of the last update" and "the value at that time"—can be consolidated into the single variable pointer_time.

3.4 The relationship between duration and half-life

The duration used in this article is defined not as a half-life but as the integral of the exponential-decay kernel—that is, the effective window width. If the weight for elapsed time \(a\) is \(w(a) = \exp(-a / \mathrm{duration})\), then the total area of the weights is duration itself, as shown below.
\[ \int_0^\infty \exp\left(-\frac{a}{\mathrm{duration}}\right) da = \mathrm{duration} \]
Therefore, when events arrive at a constant rate of \(r\) per second, the steady-state value of the exponential-decay counter is \(r \times \mathrm{duration}\). In other words, duration = 1 gives a value that reads naturally as a per-second rate, duration = 60 as a per-minute rate, and duration = 3600 as a per-hour rate.
On the other hand, the half-life \(h\) is defined as the time it takes for the weight to halve—that is, the \(h\) that satisfies the following.
\[ \exp\left(-\frac{h}{\mathrm{duration}}\right) = \frac{1}{2} \]
Solving this gives the half-life as follows.
\[ h = \mathrm{duration} \times \ln 2 \]
Therefore, a counter with duration = 60 does not have a "half-life of 60 seconds"; it is a counter with a half-life of about 41.6 seconds. Here, to make the rate readable, we use duration as the effective window width rather than the half-life.

4. Implementation: storing it as a fixed-point time

The key implementation point of this technique is to convert pointer_time into an integer and store it in an AtomicI64, rather than sharing it directly as a floating-point number. Because the state fits in a single variable, reading and updating can be completed with a single CAS (Compare-And-Swap).

4.1 Storage format and scale factor

The stored value is a fixed-point integer obtained by multiplying the pointer_time in seconds by a constant scale factor.
\[ \mathrm{raw} = \operatorname{round}(\mathrm{pointer\_time} \times \mathrm{scale}) \]
For example, with scale = 1_000_000, the storage unit is one microsecond. Since the maximum value of i64 is about \(9.22 \times 10^{18}\), even with this scale factor the representable time span is about 290,000 years. For the use case of storing seconds elapsed from the origin of a monotonic clock, overflow is usually not a problem.
On the other hand, if you make the scale factor too small, a single event's update is easily lost to rounding at high rates. If the value is \(v\), the increment is 1, and the time scale is \(\tau\), then the amount by which pointer_time moves per addition is roughly \(\tau / v\). In steady state, \(v \simeq \mathrm{rate} \times \tau\), so the change in the integer value is roughly as follows.
\[ \mathrm{raw\_delta} \simeq \frac{\mathrm{scale}}{\mathrm{rate}} \]
In other words, scale = 10_000 is practical up to a few thousand events per second per counter, but for use cases exceeding 10,000 events per second the effect of quantization begins to show. For a general-purpose implementation, using scale = 1_000_000 as the default gives a good balance between resolution and overflow headroom.

4.2 Management with an atomic variable

Because the stored value fits in a single integer, the counter itself can be represented with just an AtomicI64. The duration and the scale factor are configuration values of the counter, not state that is atomically rewritten on every update.
use std::sync::atomic::{AtomicI64, Ordering};

const SCALE: f64 = 1_000_000.0;

pub struct RateCounter {
    pointer_time: AtomicI64,
    duration_secs: f64,
}

impl RateCounter {
    pub fn new(duration_secs: f64) -> Self {
        assert!(duration_secs.is_finite() && duration_secs > 0.0);

        Self {
            pointer_time: AtomicI64::new(i64::MIN),
            duration_secs,
        }
    }
}
The initial value is set to i64::MIN. With scale = 1_000_000, this corresponds to a pointer_time about 290,000 years in the past, so decoding it to the current value yields essentially 0. The advantage is that you do not need a separate branch for the empty state; you can handle it with the ordinary decay computation alone.

4.3 Decode, increment, and re-encode

For reading, you convert the stored integer back into a pointer_time in seconds and reconstruct the value from its difference with the current time. Since the initial value i64::MIN is simply treated as a very old time, no special branch is needed.
fn decode_value(raw: i64, now_secs: f64, duration_secs: f64) -> f64 {
    let pointer_time = raw as f64 / SCALE;
    ((pointer_time - now_secs) / duration_secs).exp()
}

fn encode_time(pointer_time: f64) -> i64 {
    (pointer_time * SCALE)
        .round()
        .clamp(i64::MIN as f64, i64::MAX as f64) as i64
}
For the increment, you read the current stored value, convert it back to the current value, add the increment, and then convert it back into a pointer_time. If another thread updated it first, the CAS fails, so you redo the same computation using the new value.
impl RateCounter {
    pub fn value_at(&self, now_secs: f64) -> f64 {
        let raw = self.pointer_time.load(Ordering::Relaxed);
        decode_value(raw, now_secs, self.duration_secs)
    }

    pub fn increment_at(&self, now_secs: f64, x: f64) {
        assert!(now_secs.is_finite());
        assert!(x.is_finite() && x > 0.0);

        let mut raw = self.pointer_time.load(Ordering::Relaxed);
        loop {
            let old_value = decode_value(raw, now_secs, self.duration_secs);
            let new_value = old_value + x;
            let new_pointer_time = now_secs + self.duration_secs * new_value.ln();
            let new_raw = encode_time(new_pointer_time);

            match self.pointer_time.compare_exchange_weak(
                raw,
                new_raw,
                Ordering::Relaxed,
                Ordering::Relaxed,
            ) {
                Ok(_) => return,
                Err(actual) => raw = actual,
            }
        }
    }
}
This counter is not meant to express an ordering relationship with other memory state; it is meant to update a standalone approximate value, so Ordering::Relaxed is usually sufficient. Only when you want the counter value to express a happens-before relationship with the event-processing body itself should you consider a stronger ordering.
This implementation assumes that duration_secs is a positive finite value and that now_secs and the increment x are also finite. As long as these conditions hold, the initial value i64::MIN is naturally treated as a very old pointer_time, and it returns to an ordinary value on the first addition.

4.4 Implementation notes

For time, use a monotonically increasing monotonic clock rather than the system clock. The system clock can move backward due to NTP correction or manual changes, which in exponential-decay computations causes the value to increase or decrease unnaturally. In Rust, it is easiest to use the seconds elapsed from an Instant taken at process start.
use std::time::Instant;

let origin = Instant::now();
let now_secs = origin.elapsed().as_secs_f64();
Also, for a counter that has not been updated for a very long time, the exp at decode time may underflow to 0. In this case the value can be regarded as having decayed to essentially 0, so for most use cases it is not a problem. The initial value i64::MIN also takes advantage of this property.
In summary, an easy-to-handle default configuration is to store pointer_time * 1_000_000 in an AtomicI64, initialize it to i64::MIN, and decode, add, and re-encode in a CAS loop.

5. Mathematical background

This technique can be explained as a special case of an exponentially decayed aggregate.
The exponential-decay count \(D(t)\) when an event with weight \(w_i\) occurs at time \(t_i\) can be defined as follows.
\[ D(t) = \sum_i w_i \times \exp\left(-\frac{t - t_i}{\tau}\right) \]
Transforming this gives the following.
\[ D(t) = \exp\left(-\frac{t}{\tau}\right) \times \sum_i w_i \times \exp\left(\frac{t_i}{\tau}\right) \]
In other words, the part that depends on the read time \(t\) can be factored out of the sigma, so the state \(S\) that the system must hold is as follows.
\[ S = \sum_i w_i \times \exp\left(\frac{t_i}{\tau}\right) \]
Converting this \(S\) into the logarithmic domain gives the following.
\[ p = \tau \times \log(S) \]
This corresponds to the proposed method's pointer_time. Moreover, the increment operation is equivalent to a log-sum-exp computation.
\[ p_{\mathrm{new}} = \tau \times \log\left( \exp\left(\frac{p_{\mathrm{old}}}{\tau}\right) + x \times \exp\left(\frac{t}{\tau}\right) \right) \]
In an actual implementation, this is handled efficiently by decoding once, adding, and encoding again.

6. Summary

In system operations, grasping the "amount of change (rate)" is also important. An ordinary exponential-decay counter needs to hold two pieces of state, but the proposed method achieves it with a single time-like parameter (pointer_time).
The characteristics of this technique are as follows.
  • The current value can be reconstructed with \(\exp((\mathrm{pointer\_time} - \mathrm{now}) / \mathrm{duration})\)
  • Addition is completed in three steps: decode the stored value, add, and re-encode
  • It can be managed with an atomic variable such as AtomicI64
It is not suited for counting events over a strict period, but it is a lightweight technique well suited to grasping "current trends" such as the momentum of load or errors.

7. References

The exponentially decayed aggregate, the comparison with sliding windows, rate measurement via EWMA, and the numerical computation of log-sum-exp in this article are based on the following literature and implementation standards.
  1. Cohen, E., & Strauss, M. J. (2006). "Maintaining Time-Decaying Stream Aggregates". Journal of Algorithms, 59, 19-36. A foundational reference for treating time-decaying stream aggregates in general.
  2. Cormode, G., Korn, F., & Tirthapura, S. (2008). "Exponentially Decayed Aggregates on Data Streams". ICDE 2008. A reference that organizes techniques for handling exponentially decayed aggregates over data streams, close to the mathematical background of this article.
  3. Cormode, G., Shkapenyuk, V., Srivastava, D., & Xu, B. (2009). "Forward Decay: A Practical Time Decay Model for Streaming Systems". ICDE 2009. It introduces the idea of expressing decay forward from a fixed point in time, useful as background for an easy-to-implement time-decay model.
  4. Datar, M., Gionis, A., Indyk, P., & Motwani, R. (2002). "Maintaining Stream Statistics over Sliding Windows". SIAM Journal on Computing. A classic reference on sliding-window stream statistics, relevant to the comparison with bucket- and window-width-based methods.
  5. NIST/SEMATECH Engineering Statistics Handbook, "EWMA Control Charts". It explains the basic idea of EWMA and the practical background of a monitoring technique that gives exponentially decreasing weight to past observations.
  6. Dropwizard Metrics documentation, "Meters". An example of rate measurement in a practical metrics library, useful as a reference for a design that handles 1-, 5-, and 15-minute moving averages in addition to the mean rate.
  7. SciPy documentation, scipy.special.logsumexp. A standard function for numerically stable computation of log-sum-exp, helpful for understanding the addition in the logarithmic domain in this article.

Last Modified: June 19, 2026