Environment
- baileys
7.0.0-rc13 (current latest on npm)
- Node 24, single WhatsApp account, no history sync beyond
Reconnection with existing sync data
- Observed on a production instance, reproduced by inspecting the raw stanza before decoding
- Verified against
master as of 2026-07-25: both code paths below are unchanged since rc13
(handleReceipt's non-group update, and the messages.upsert merge in event-buffer)
Summary
For messages delivered through the offline queue after a reconnect, messageTimestamp in
messages.upsert can be later than the actual send time — it becomes the timestamp of the
read/delivery receipt for that same message.
decodeMessageNode sets the correct value (messageTimestamp: +stanza.attrs.t), but the buffered
event pipeline overwrites it afterwards, so the corruption is invisible in the emitted events —
you only see it if you compare against the raw <message> stanza attrs.t.
Concrete case from our logs: an incoming message sent at 01:06:01Z was emitted with
messageTimestamp = 01:07:36Z (+95s), i.e. later than our own reply to it (01:06:27Z).
That later value is exactly when the message was read. Official clients (phone, desktop) show
01:06.
Impact
Any consumer that persists messageTimestamp as the send time stores a wrong value, and it looks
plausible (no error, no duplicate event), so it is easy to miss. In our case the value is written
once on insert and is not correctable after the fact. Message ordering in a chat also breaks:
a question can appear after the answer to it.
Root cause
1. The whole offline flush is processed inside a single event buffer.
Socket/socket.ts starts buffering on login when credentials exist, and flushes only on
CB:ib,,offline (the handled N offline messages/notifications log line):
// src/Socket/socket.ts — makeSocket, start of the initial event buffer
let didStartBuffer = false
process.nextTick(() => {
if (creds.me?.id) { ev.buffer(); didStartBuffer = true }
...
})
// src/Socket/socket.ts — flush once all offline notifications are handled
ws.on('CB:ib,,offline', node => {
...
if (didStartBuffer) { ev.flush() }
})
The offline node processor routes both messages and receipts through that same buffer:
// src/Socket/messages-recv.ts — offlineNodeProcessor
const offlineNodeProcessor = makeOfflineNodeProcessor(new Map([
['message', handleMessage],
['call', handleCall],
['receipt', handleReceipt],
['notification', handleNotification]
]), { ... })
2. handleReceipt puts the receipt's own timestamp into messageTimestamp (1:1 chats only).
// src/Socket/messages-recv.ts — handleReceipt, non-group branch
ev.emit('messages.update', ids.map(id => ({
key: { ...key, id },
update: { status, messageTimestamp: toNumber(+(attrs.t ?? 0)) }
})))
Note the asymmetry a few lines above: for groups / status broadcasts the same handler emits
message-receipt.update with dedicated receiptTimestamp / readTimestamp fields and does not
touch messageTimestamp:
// src/Socket/messages-recv.ts — handleReceipt, group / status-broadcast branch
const updateKey = status === proto.WebMessageInfo.Status.DELIVERY_ACK ? 'receiptTimestamp' : 'readTimestamp'
ev.emit('message-receipt.update', ids.map(id => ({
key: { ...key, id },
receipt: { userJid: jidNormalizedUser(attrs.participant), [updateKey]: +attrs.t }
})))
So the group path already models this correctly; only the 1:1 path conflates "when the receipt
happened" with "when the message was sent".
3. The buffer merges that pending update into the upsert wholesale.
// src/Utils/event-buffer.ts — append(), case 'messages.upsert'
if (data.messageUpdates[key]) {
logger.debug('absorbed prior message update in message upsert')
Object.assign(message, data.messageUpdates[key].update) // <-- messageTimestamp of the receipt lands on the message
delete data.messageUpdates[key]
}
Object.assign copies status (desired) and messageTimestamp (not desired). Since both the
receipt and the message are inside the same buffer window, and the receipt can be dequeued first,
the message ends up carrying the receipt's time. Only one messages.upsert is emitted, so nothing
in the public event stream reveals the substitution.
There is a second, related overwrite in the same branch (same class of problem, different source):
// src/Utils/event-buffer.ts — same branch, a few lines above
let existing = data.messageUpserts[key]?.message
if (!existing) {
existing = data.historySets.messages[key]
if (existing) logger.debug({ messageId: key }, 'absorbed message upsert in message set')
}
if (existing) {
message.messageTimestamp = existing.messageTimestamp
}
Why it only happens on the offline flush
On the live path each node is wrapped in its own buffer window:
// src/Socket/messages-recv.ts — processNodeWithBuffer
const processNodeWithBuffer = async (node, identifier, exec) => {
ev.buffer()
await execTask()
ev.flush()
...
}
so a receipt always arrives after the message's upsert has been flushed and there is nothing to
merge into. The offline queue is the only place where a receipt can be buffered before the
message it refers to. Accordingly, in one flush of 27 messages only the 2 most recent ones were
affected (those that had a pending receipt); everything older was correct.
Steps to reproduce
- Connect, then stop the baileys client (keep credentials).
- From another account, send a 1:1 message to the account.
- Make sure the message gets read — from the phone / another linked device, or simply let your
client mark it read right after connecting.
- Start the baileys client again so the message and its receipt arrive in the same offline flush.
- Compare
messageTimestamp in messages.upsert with the raw stanza:
sock.ws.on('CB:message', node => {
console.log('[raw]', node.attrs.id, node.attrs.t)
})
sock.ev.on('messages.upsert', up => {
for (const m of up.messages) console.log('[upsert]', up.type, m.key.id, m.messageTimestamp)
})
With logger.level = 'debug' the merge is also visible as
absorbed prior message update in message upsert.
Observed output (anonymized)
Own device: <own-pn>:66@s.whatsapp.net / <own-lid>:66@lid, peer chat <peer-lid>@lid.
Client was offline for ~8.5h; reconnect at 01:08:20Z.
{"msg":"offline preview received"}
{"msg":"opened connection to WA"}
{"msg":"handled 85 offline messages/notifications"}
# incoming message, actually sent at 01:06:01Z
[raw] ACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA t=1784941561 (2026-07-25T01:06:01Z)
[upsert] append ACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA messageTimestamp=1784941656 (2026-07-25T01:07:36Z) # +95s == time it was read
# our own outgoing message (sent from the phone), actually sent at 01:06:27Z
[raw] 3EB0AAAAAAAAAAAAAAAA t=1784941587 (2026-07-25T01:06:27Z)
[upsert] append 3EB0AAAAAAAAAAAAAAAA messageTimestamp=1784941588 (2026-07-25T01:06:28Z) # +1s == delivery ack
The +95s value is later than our own reply to that message (01:06:27Z), which is what made the
corruption noticeable. Older messages in the same flush (gaps from 20 minutes to 6.5 hours) were
all correct — raw t == messageTimestamp.
Ruled out during the investigation: retries and session re-establishment (no
session will be re-established for this chat, no decryption failures in the window), and
duplicate stanzas (a raw <message> attrs dump showed exactly one node per id, and only one
messages.upsert is emitted).
Suggested fix
Merging status from a pending receipt into the upsert is useful; merging messageTimestamp is
not — a receipt timestamp is not the message timestamp. Either:
- (preferred) stop putting
messageTimestamp into the receipt's update in the 1:1 branch of
handleReceipt, mirroring what the group branch already does (receiptTimestamp /
readTimestamp are separate fields); or
- exclude
messageTimestamp from the Object.assign in event-buffer's messages.upsert branch,
so an update can never redefine when a message was sent.
The existing.messageTimestamp assignment a few lines above deserves the same look: overwriting a
freshly decoded stanza timestamp with a buffered/history one is the same class of issue.
Workaround (for other consumers hitting this)
Subscribe to the raw node before decoding and prefer the stanza's t:
const rawTs = new Map() // id -> t, bounded
sock.ws.on('CB:message', node => {
const { id, t } = node.attrs || {}
if (id && t && !rawTs.has(id)) rawTs.set(id, Number(t))
})
sock.ev.on('messages.upsert', up => {
for (const m of up.messages) {
const raw = rawTs.get(m.key.id)
if (raw && raw !== Number(m.messageTimestamp)) m.messageTimestamp = raw
}
})
CB:message fires before the decoded message reaches messages.upsert, so the map is populated in
time; a miss simply leaves the decoded value untouched.
Environment
7.0.0-rc13(currentlateston npm)Reconnection with existing sync datamasteras of 2026-07-25: both code paths below are unchanged since rc13(
handleReceipt's non-groupupdate, and themessages.upsertmerge inevent-buffer)Summary
For messages delivered through the offline queue after a reconnect,
messageTimestampinmessages.upsertcan be later than the actual send time — it becomes the timestamp of theread/delivery receipt for that same message.
decodeMessageNodesets the correct value (messageTimestamp: +stanza.attrs.t), but the bufferedevent pipeline overwrites it afterwards, so the corruption is invisible in the emitted events —
you only see it if you compare against the raw
<message>stanzaattrs.t.Concrete case from our logs: an incoming message sent at
01:06:01Zwas emitted withmessageTimestamp=01:07:36Z(+95s), i.e. later than our own reply to it (01:06:27Z).That later value is exactly when the message was read. Official clients (phone, desktop) show
01:06.Impact
Any consumer that persists
messageTimestampas the send time stores a wrong value, and it looksplausible (no error, no duplicate event), so it is easy to miss. In our case the value is written
once on insert and is not correctable after the fact. Message ordering in a chat also breaks:
a question can appear after the answer to it.
Root cause
1. The whole offline flush is processed inside a single event buffer.
Socket/socket.tsstarts buffering on login when credentials exist, and flushes only onCB:ib,,offline(thehandled N offline messages/notificationslog line):The offline node processor routes both messages and receipts through that same buffer:
2.
handleReceiptputs the receipt's own timestamp intomessageTimestamp(1:1 chats only).Note the asymmetry a few lines above: for groups / status broadcasts the same handler emits
message-receipt.updatewith dedicatedreceiptTimestamp/readTimestampfields and does nottouch
messageTimestamp:So the group path already models this correctly; only the 1:1 path conflates "when the receipt
happened" with "when the message was sent".
3. The buffer merges that pending update into the upsert wholesale.
Object.assigncopiesstatus(desired) andmessageTimestamp(not desired). Since both thereceipt and the message are inside the same buffer window, and the receipt can be dequeued first,
the message ends up carrying the receipt's time. Only one
messages.upsertis emitted, so nothingin the public event stream reveals the substitution.
There is a second, related overwrite in the same branch (same class of problem, different source):
Why it only happens on the offline flush
On the live path each node is wrapped in its own buffer window:
so a receipt always arrives after the message's upsert has been flushed and there is nothing to
merge into. The offline queue is the only place where a receipt can be buffered before the
message it refers to. Accordingly, in one flush of 27 messages only the 2 most recent ones were
affected (those that had a pending receipt); everything older was correct.
Steps to reproduce
client mark it read right after connecting.
messageTimestampinmessages.upsertwith the raw stanza:With
logger.level = 'debug'the merge is also visible asabsorbed prior message update in message upsert.Observed output (anonymized)
Own device:
<own-pn>:66@s.whatsapp.net/<own-lid>:66@lid, peer chat<peer-lid>@lid.Client was offline for ~8.5h; reconnect at
01:08:20Z.The
+95svalue is later than our own reply to that message (01:06:27Z), which is what made thecorruption noticeable. Older messages in the same flush (gaps from 20 minutes to 6.5 hours) were
all correct —
raw t == messageTimestamp.Ruled out during the investigation: retries and session re-establishment (no
session will be re-establishedfor this chat, no decryption failures in the window), andduplicate stanzas (a raw
<message>attrs dump showed exactly one node per id, and only onemessages.upsertis emitted).Suggested fix
Merging
statusfrom a pending receipt into the upsert is useful; mergingmessageTimestampisnot — a receipt timestamp is not the message timestamp. Either:
messageTimestampinto the receipt'supdatein the 1:1 branch ofhandleReceipt, mirroring what the group branch already does (receiptTimestamp/readTimestampare separate fields); ormessageTimestampfrom theObject.assigninevent-buffer'smessages.upsertbranch,so an update can never redefine when a message was sent.
The
existing.messageTimestampassignment a few lines above deserves the same look: overwriting afreshly decoded stanza timestamp with a buffered/history one is the same class of issue.
Workaround (for other consumers hitting this)
Subscribe to the raw node before decoding and prefer the stanza's
t:CB:messagefires before the decoded message reachesmessages.upsert, so the map is populated intime; a miss simply leaves the decoded value untouched.