Recreating WeChat‑Style Message Recall with Spring Boot: Why Deleting Isn’t the Hard Part

The article walks through building a true WeChat‑like message recall feature in Spring Boot, explaining why simply deleting a row is insufficient and detailing the required status change, concurrency‑safe conditional update, multi‑device session handling, and cross‑instance event propagation.

LuTiao Programming
LuTiao Programming
LuTiao Programming
Recreating WeChat‑Style Message Recall with Spring Boot: Why Deleting Isn’t the Hard Part

When implementing instant‑messaging, the author initially thought that recalling a message was as simple as executing DELETE FROM chat_message WHERE id = ?, but quickly discovered that a message may already reside on the sender’s phone, the receiver’s phone, web clients, Redis, WebSocket connections, and offline queues, so deleting the row does not remove it from users’ screens.

The real recall operation is to change a message’s state from NORMAL to RECALLED and synchronize this state to every connected device.

1. Redesign the schema

The original chat_message table only stored the message content. To support recall, two new columns are added:

ALTER TABLE chat_message ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'NORMAL';
ALTER TABLE chat_message ADD COLUMN recalled_at DATETIME NULL;

An enum defines the possible states: public enum MessageStatus { NORMAL, RECALLED } When a message is recalled, its content may be kept or masked, status is set to RECALLED, and recalled_at records the current time.

2. Sending a message

A simple REST endpoint stores the message in MySQL and then pushes it to online users via Spring’s native WebSocket support:

@PostMapping("/messages")
public SendMessageResult send(@RequestBody SendMessageRequest request, Authentication authentication) {
    Long senderId = currentUserId(authentication);
    return messageService.send(senderId, request);
}

@Transactional
public SendMessageResult send(Long senderId, SendMessageRequest request) {
    ChatMessage message = new ChatMessage();
    message.setId(idGenerator.nextId());
    message.setConversationId(request.conversationId());
    message.setSenderId(senderId);
    message.setReceiverId(request.receiverId());
    message.setContent(request.content());
    message.setStatus(MessageStatus.NORMAL);
    message.setCreatedAt(LocalDateTime.now());
    messageRepository.save(message);
    return SendMessageResult.from(message);
}

After persisting, a MessageEvent is generated and sent through the WebSocket channel so that every online client displays the new message.

3. Recall API constraints

The system enforces business rules: only the sender may recall, the recall must occur within two minutes of sending, and a message can be recalled only once.

@PostMapping("/messages/{messageId}/recall")
public void recall(@PathVariable Long messageId, Authentication authentication) {
    messageService.recall(messageId, currentUserId(authentication));
}

4. Concurrency‑safe conditional UPDATE

Rather than a SELECT followed by an UPDATE, a single SQL statement checks all conditions atomically:

UPDATE chat_message
SET status = 'RECALLED',
    recalled_at = NOW()
WHERE id = :messageId
  AND sender_id = :senderId
  AND status = 'NORMAL'
  AND created_at >= :deadline;

In Java the affected row count is examined:

int affected = messageRepository.recall(messageId, senderId, LocalDateTime.now().minusMinutes(2));
if (affected != 1) {
    throw new MessageRecallException("Message not found, already recalled, or past recall window");
}

If two devices attempt to recall simultaneously, only one will succeed (affectedRows = 1); the other receives 0 rows and fails gracefully, eliminating the need for explicit locks.

5. Publishing the recall event

After the database update, a MessageRecalledEvent is published:

eventPublisher.publish(new MessageRecalledEvent(
    message.getId(),
    message.getConversationId(),
    message.getSenderId(),
    message.getReceiverId()
));

In a single‑instance deployment the event stays in JVM memory; in a multi‑instance deployment it is forwarded via Redis Pub/Sub, Kafka, RocketMQ, or Redis Streams so that every node can deliver the event to its own WebSocket sessions.

6. WebSocket handling

The WebSocket layer receives the MESSAGE_RECALLED event, looks up all sessions belonging to the sender and receiver (including multiple devices), and pushes a payload such as: {"event":"MESSAGE_RECALLED","messageId":10001} Clients replace the original content with a placeholder like “You recalled a message” or “The other party recalled a message”.

7. Multi‑device session management

Because a user may be logged in on several devices, the server stores a collection of sessions per userId instead of a single WebSocket connection:

10086 →
    ├─ iphone‑session
    ├─ mac‑session
    └─ chrome‑session

When a recall occurs, the event is sent to all of the user’s active sessions as well as the counterpart’s sessions.

8. Scaling to many instances

With a gateway routing to multiple Spring Boot instances, a recall request may hit instance A while the receiver’s WebSocket connection lives on instance B. Therefore the recall event must be persisted (e.g., in Redis or a message queue) and broadcast to all nodes, which then forward it to the appropriate WebSocket sessions.

9. Offline recipients

If the receiver is offline, the database already reflects status = RECALLED. When the client reconnects and queries the conversation history, it receives the updated status and displays “The other party recalled a message”, ensuring eventual consistency even without a real‑time WebSocket push.

10. Full recall workflow

User sends message → save to MySQL → WebSocket pushes to all devices
User clicks recall → Java validates sender & time → conditional UPDATE (NORMAL→RECALLED)
→ generate MESSAGE_RECALLED event → MQ/Redis propagates across nodes
→ each node pushes to all sender/receiver sessions → clients replace content with placeholder
→ offline devices sync from DB on next query

The implementation showcases several backend concepts: state machines, conditional updates, idempotency, concurrency control, WebSocket integration, multi‑device session tracking, cross‑instance message propagation, offline synchronization, and eventual consistency.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Spring BootConcurrency ControlInstant MessagingMessage RecallConditional Update
LuTiao Programming
Written by

LuTiao Programming

LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.