Debouncing Rapid Feature Edits

Key the debounce on the feature identifier, give it a maximum wait so a continuously edited feature still emits, and merge the burst so the event carries the geometry from before the first edit and after the last — keeping the most recent intermediate geometry leaves every tile the feature has left behind serving a stale shape.

This guide sits under Feature Change Triggers, within Core Event Fundamentals & Architecture. It handles the burst that Capturing PostGIS Changes with Logical Replication faithfully produces, one event per vertex drag.

When to use this pattern

  • A human editor or a bulk process produces many edits to the same feature within seconds, and each one currently triggers downstream work.
  • The downstream work is expensive relative to the edit — a tile rebuild, a spatial index update, a notification — so collapsing ten events into one is a real saving.
  • Losing the intermediate states is acceptable to the consumer. If an audit trail needs every edit, debounce in front of the expensive consumer only, and leave the topic intact.

What a burst actually looks like

Eleven events, one meaningful change An operator reshapes a polygon over about twelve seconds, releasing the mouse eleven times. Each release commits a transaction, so the capture stage emits eleven change events, and a tile pipeline downstream rebuilds every affected tile eleven times — of which ten rebuilds are immediately superseded by the next. The work is not merely wasted: each rebuild also invalidates a cache entry that clients then re-fetch, so a twelve-second edit produces eleven cache-invalidation waves across every viewer of that area. With a debounce keyed on the feature, the eleven events become one, emitted three hundred milliseconds after the operator stops. The saving is proportional to burst length, and burst length is set by how carefully somebody is working — which means the busiest editing sessions produce the largest bursts and the biggest saving, and also that a naive timer without a maximum wait fails hardest on exactly those sessions. one operator reshaping one polygon · 12 seconds without debounce 11 change events → 11 tile rebuilds → 11 cache-invalidation waves across every viewer with a 300 ms per-feature debounce timer resets on each edit one event, 300 ms after the operator stops Burst length is set by how carefully somebody is working, so the busiest sessions produce the biggest saving — and also the longest chains of resets, which is exactly where a timer without a maximum wait goes silent.
Figure 1. Ten of the eleven rebuilds are superseded before anyone sees them, and each one still costs a cache invalidation for every viewer of that area.

Complete runnable implementation

python
import asyncio
import time
from dataclasses import dataclass, field

from shapely.geometry import mapping, shape
from shapely.ops import unary_union

QUIET_SECONDS = 0.3      # emit this long after the last edit
MAX_WAIT_SECONDS = 5.0   # …but never hold an edit longer than this


@dataclass(slots=True)
class Pending:
    """State for one feature's in-flight burst."""
    first_seen: float
    event: dict
    handle: asyncio.TimerHandle | None = None
    merged: int = 1


class FeatureDebouncer:
    def __init__(self, emit, quiet: float = QUIET_SECONDS,
                 max_wait: float = MAX_WAIT_SECONDS) -> None:
        self._emit = emit
        self._quiet = quiet
        self._max_wait = max_wait
        self._pending: dict[str, Pending] = {}

    def submit(self, event: dict) -> None:
        """Accept one change event; emit later, merged."""
        loop = asyncio.get_running_loop()
        key = event["feature_id"]
        now = loop.time()
        current = self._pending.get(key)

        if current is None:
            self._pending[key] = current = Pending(first_seen=now, event=event)
        else:
            current.event = _merge(current.event, event)
            current.merged += 1
            if current.handle is not None:
                current.handle.cancel()

        # The maximum wait is measured from the FIRST edit of the burst, so a
        # feature under continuous editing still emits. Without this line a
        # four-minute reshaping session produces nothing for four minutes.
        deadline = min(now + self._quiet, current.first_seen + self._max_wait)
        current.handle = loop.call_at(deadline, self._fire, key)

    def _fire(self, key: str) -> None:
        pending = self._pending.pop(key, None)
        if pending is None:
            return
        pending.event["merged_edits"] = pending.merged
        pending.event["burst_seconds"] = round(
            asyncio.get_running_loop().time() - pending.first_seen, 3
        )
        asyncio.create_task(self._emit(pending.event))

    async def drain(self) -> None:
        """Flush every pending timer. MUST run on shutdown.

        Without it, the last edit of every in-flight burst is dropped — the
        one edit the operator most recently made, and the only one they will
        look for.
        """
        for key in list(self._pending):
            handle = self._pending[key].handle
            if handle is not None:
                handle.cancel()
            self._fire(key)
        await asyncio.sleep(0)


def _merge(old: dict, new: dict) -> dict:
    """Combine two change events for the same feature.

    The geometry is the newest one; the previous_geometry is the OLDEST,
    because the merged event has to describe the whole burst. Taking the
    newest previous_geometry describes only the final drag, leaving every
    tile the feature occupied at the start of the burst uninvalidated.
    """
    merged = dict(new)
    merged["previous_geometry"] = old.get("previous_geometry")
    merged["occurred_at"] = new["occurred_at"]

    # An insert followed by updates is still an insert to anyone downstream.
    if old["action"] == "insert":
        merged["action"] = "insert"
    # …and anything followed by a delete is a delete.
    if new["action"] == "delete":
        merged["action"] = "delete"
        merged["geometry"] = None
    return merged


def affected_area(event: dict):
    """Union of where the feature was and where it is — what to invalidate."""
    parts = [shape(g) for g in (event.get("previous_geometry"), event.get("geometry")) if g]
    return mapping(unary_union(parts)) if parts else None
Keep the first previous-geometry, or leave tiles behind A polygon is dragged across a tile grid during one editing burst, from the upper-left tiles to the lower-right ones through two intermediate positions. If the merged event keeps the oldest previous-geometry, the affected area is the union of the starting shape and the final shape, so every tile the feature occupied at the start and every tile it occupies now are invalidated, and the map is consistent. If the merge instead keeps the most recent intermediate previous-geometry — which is what a naive last-write-wins merge produces — the affected area covers only the final drag, so the tiles the feature left at the beginning of the burst are never invalidated. Those tiles keep serving the polygon in its original position, and nothing will correct them until some unrelated edit happens to touch the same tile, which for a quiet rural area can be months. The symptom is a feature that appears twice on the map at two different zoom levels, and it is impossible to reproduce from a single edit because it needs a burst. one polygon dragged across a tile grid during a single burst start end keep the OLDEST previous_geometry affected area = union(start, end) every tile it left and every tile it now occupies is invalidated the map is consistent after one event costs one extra geometry in the payload keep the NEWEST — last-write-wins affected area = union(3rd position, end) the two tiles it left at the start of the burst are never invalidated they keep serving the original shape until an unrelated edit touches the same tile in a quiet area, that can be months The symptom is a feature that appears in two places on the map. It cannot be reproduced from a single edit, because it requires a burst — which is why the merge rule is worth a comment in the code rather than a line in a runbook.
Figure 2. The naive merge is not merely less accurate; it produces map state that will not self-correct, because nothing downstream knows those tiles are wrong.

Parameter reference

Name Type Spatial constraint Default
QUIET_SECONDS float Above the interval between vertex drags (~150–250 ms for a human) 0.3
MAX_WAIT_SECONDS float Below the freshness objective for the affected tiles 5.0
Debounce key str The feature id — never the stream or the tile, or unrelated edits collapse
previous_geometry GeoJSON Must be the burst’s first, so the invalidation area is complete
merged_edits int Emitted for observability; a value of 1 means the debounce did nothing 1
drain() coroutine Must run on shutdown, or the last edit of every burst is lost

Gotchas and spatial edge cases

  1. Debouncing on the stream instead of the feature collapses unrelated edits. With one timer for the whole stream, an operator editing a parcel in Hamburg suppresses an unrelated edit in Munich, and the second feature’s event carries the first feature’s geometry. The key must be the feature identifier, and the memory cost of one timer per in-flight feature is the price of correctness.

  2. A delete inside a burst wins, and it must null the geometry. An insert-then-delete pair inside the quiet window collapses to a delete of something no consumer ever saw. That is correct, and it means consumers must tolerate a delete for an unknown feature rather than treating it as an error.

  3. MAX_WAIT_SECONDS has to sit below the freshness objective. Debouncing deliberately delays events, which spends the freshness budget defined in SLOs & Alerting for Spatial Webhook Pipelines. A five-second maximum against a sixty-second objective is comfortable; a sixty-second maximum against the same objective consumes the entire budget before the pipeline has done any work.

  4. The debouncer is in-memory state, so it changes the delivery guarantee. A process killed with pending timers loses those bursts unless drain() runs. If that is unacceptable, keep the pending state in Redis with a TTL and accept the round trip — but do not pretend an in-memory dictionary survives a deploy.

  5. Union of the before and after geometry is not always the right invalidation area. For a feature that moved a long way, the union’s bounding box covers everything in between, which can be thousands of untouched tiles. Invalidate against the two geometries separately rather than their combined envelope, as Scoping Tile Invalidation to the Zoom Levels That Changed describes.

The edit the operator will look for is the one drain() saves A deploy restarts the debouncing service while four features have pending timers. Without a drain step the process exits and those four timers are cancelled with the events still buffered, so the last edit made to each of those features never reaches any consumer. That is precisely the edit the operator will look for: it is the most recent one they made, they saw it save successfully in the editor, and the map does not show it. Nothing errors, because from the debouncer's point of view the process simply ended. With drain, every pending timer is fired immediately on shutdown, so the four merged events are emitted before the process exits — they arrive slightly earlier than the quiet period would have allowed, which is harmless, and no edit is lost. The cost is a few milliseconds on shutdown; the alternative is a class of missing edits that correlates perfectly with deploy times and is therefore attributed to whatever else shipped. shutdown without drain() f-1 pending f-2 pending f-3 pending f-4 pending process exits · four timers cancelled, four edits gone the lost edit is the most recent one the operator made — they watched it save, and the map does not show it nothing errors; from the debouncer's view the process simply ended shutdown with drain() f-1 emitted f-2 emitted f-3 emitted f-4 emitted then the process exits events arrive slightly earlier than the quiet period would allow, which is harmless Cost: a few milliseconds on shutdown. Alternative: missing edits that correlate exactly with deploy times, and are blamed on whatever else shipped.
Figure 3. A debouncer holds state that nothing else knows about, so shutdown is the one moment it can lose data — and the data it loses is the newest.

Verification

python
import asyncio
import pytest

SQUARE = {"type": "Polygon", "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]}
MOVED = {"type": "Polygon", "coordinates": [[[5, 5], [5, 6], [6, 6], [6, 5], [5, 5]]]}


@pytest.mark.asyncio
async def test_burst_collapses_to_one_event():
    out = []
    d = FeatureDebouncer(emit=lambda e: out.append(e) or asyncio.sleep(0), quiet=0.05)
    for i in range(11):
        d.submit({"feature_id": "f-1", "action": "update", "occurred_at": str(i),
                  "geometry": SQUARE, "previous_geometry": SQUARE})
        await asyncio.sleep(0.01)
    await asyncio.sleep(0.1)
    assert len(out) == 1 and out[0]["merged_edits"] == 11


@pytest.mark.asyncio
async def test_max_wait_fires_during_continuous_editing():
    """The failure a plain debounce has: never emitting at all."""
    out = []
    d = FeatureDebouncer(emit=lambda e: out.append(e) or asyncio.sleep(0),
                         quiet=0.05, max_wait=0.2)
    for _ in range(40):                       # 0.4 s of unbroken editing
        d.submit({"feature_id": "f-1", "action": "update", "occurred_at": "t",
                  "geometry": SQUARE, "previous_geometry": SQUARE})
        await asyncio.sleep(0.01)
    assert out, "a continuously edited feature must still emit"


def test_merge_keeps_the_first_previous_geometry():
    """The rule that keeps tiles from being left behind."""
    first = {"feature_id": "f-1", "action": "update", "occurred_at": "t1",
             "geometry": SQUARE, "previous_geometry": SQUARE}
    last = {"feature_id": "f-1", "action": "update", "occurred_at": "t9",
            "geometry": MOVED, "previous_geometry": MOVED}
    merged = _merge(first, last)
    assert merged["previous_geometry"] == SQUARE
    assert merged["geometry"] == MOVED

The second test is worth running with a real clock rather than a mocked one. A mocked loop makes the maximum-wait bug invisible, because the reset chain that causes it only appears when the edits genuinely arrive faster than the quiet period.