皆様は、CUTIE STREETの新曲 "Error404" をご存知だろうか。
歌詞の内容は、気になる人からのメッセージが届かなくてモヤモヤする女心を表したものである。
筆者はミントグリーン担当・板倉可奈推しであるが、今回彼女はサムネに採用されており、MVも非常に満足のいく出来であった。
さて話は戻り、エンジニアとしては、このシステムが「404エラーを出した」ことが適切だったのかが非常に気になった。
なぜ404エラーが出てしまったのか
エンジニアの皆様はご存知の通り、404エラーとは、クライアントからのリクエストに対して該当のリソースが存在しないことを表すエラーである。
このエラーは、通常「まだメッセージが来ていない」ことでは起こり得ない。メッセージ一覧を取る API では、未受信でも空配列が返るはずだからだ。
つまり、実際のところ起きていたのは、以下のような事象だと思われる。
- 気になる人(A)は、実際はメッセージを送っていた
- こちらはメッセージに気づき、該当メッセージをタップした(該当メッセージを単一取得するリクエストを送信)
- ところが、実際には A は該当メッセージを削除していた
- 削除されたリソースにアクセスしたため、API は 404 を返却した
送信取り消しをしたということは、実は気になる人もこちらを気にかけており、恋心を歌うようなメッセージを送信していたのかもしれない。なんとも趣深い状況ではないか。
404エラーが出たのは適切だったのか
さて、このケースにおいて、404エラーが出たのは適切だったのか。
人それぞれ考えはあるだろうが、少なくとも「API が返した 404 が、そのまま画面に見えている」のは適切だと言えないだろう。
利用者は、404エラーに馴染みのある人だけではない(この曲で馴染みが出てくれると嬉しいが)。
アプリ側のバグだと思われ、問い合わせが来てもおかしくはない。
したがって、この点に関しては、なんらかの対策を打つ必要がある。
対策方法は大きく分けると、以下の2つだ。
- 404エラーを catch し、「送信が取り消された」旨をユーザーに通知する。
- そもそも 404 エラーが出ないようにする。
404エラーを catch する
404エラーを catch する場合、コードは以下のようになるだろう。
async function getMessage(id: string) {
const res = await fetch(`https://api.example.com/messages/${id}`);
if (res.status === 404) {
return null;
}
return res.json();
}
export default async function MessageDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const message = await getMessage(id);
if (!message) {
return (
<div className="p-4 border rounded-md bg-gray-50 text-gray-500">
<p>このメッセージは送信者によって削除されました。</p>
</div>
);
}
return <div>{message.content}</div>;
}
これで、少なくともユーザー側としては、意味不明なエラーではなく「削除された」という事実(および相手の恋心)を知ることができるようになった。
しかしながら、この方法には欠点がある。
「本当に存在しないメッセージ」と「送信取り消しされたメッセージ」の見分けがつかないのだ。
(URL を打ち替えてアクセスした場合なども、同じ 404 として扱われてしまう。)
したがって、方法2「そもそも 404 エラーが出ないようにする」も同時に行うべきだろう。
そもそも404エラーが出ないようにする
今回の根本的な問題は、「一覧には残っているのに、詳細を開くとすでに削除されている」メッセージにアクセスできてしまったことにある。
まずはWebSocket で削除状態をリアルタイムに同期し、削除済みメッセージをタップされにくくする。

API
message.destroy # または論理削除: message.update!(is_deleted: true, text: nil)
# WebSocketで全クライアントへ削除イベントを配信
ActionCable.server.broadcast("chat_channel", {
type: "MESSAGE_DELETED",
payload: { messageId: message.id }
})
フロント
'use client';
import { useState, useEffect } from 'react';
type Message = { id: string; senderName: string; text: string };
export default function MessageList({ initialMessages }: { initialMessages: Message[] }) {
const [messages, setMessages] = useState(initialMessages);
useEffect(() => {
const socket = new WebSocket('wss://api.example.com/ws');
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'MESSAGE_DELETED') {
setMessages((prev) => prev.filter((msg) => msg.id !== data.payload.messageId));
}
};
return () => socket.close();
}, []);
return (
<div>
{messages.map((msg) => (
<a key={msg.id} href={`/messages/${msg.id}`}>
<div>{msg.senderName}</div>
<div>{msg.text}</div>
</a>
))}
</div>
);
}
これで、メッセージ一覧で削除済みメッセージをタップされる可能性はかなり減る。
ところが、これではまだ終わらない。
レコードが削除されてから全体に削除イベントが行き渡るにはラグがある。
その間に削除メッセージをタップされる可能性は、0ではない。

したがって、次は API 側の工夫が必要になる。
一覧のリアルタイム更新だけでは防ぎきれないレースに備え、削除状態そのものを API のレスポンスで返すのだ。
- そもそも論理削除にする。
- 論理削除されたレコードにアクセスされた際は、コンテンツは返さない。削除済みであることはカラムで返す。
疑似コードで表すと、以下のようになる。
API
message = Message.with_deleted.find(params[:id])
if message.is_deleted
render json: { id: message.id, is_deleted: true, content: nil }
else
render json: { id: message.id, is_deleted: false, content: message.content }
end
フロント
export default async function MessagePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const res = await fetch(`https://api.example.com/api/v1/messages/${id}`, {
cache: 'no-store',
});
const message = await res.json();
if (message.is_deleted) {
return <div>このメッセージは削除されました</div>;
}
return <div>{message.content}</div>;
}
こうすれば、たとえリアルタイム同期のラグで削除済みメッセージをタップしてしまっても、クライアントには「削除された」という状態が正しく届き、404 を露出させずに済む。
まとめ
"Error404" において、少なくとも 404 エラーがクライアントに露出していたことは、適切な設計ではない。
クライアントに 404 を出さないためには、削除状態を API のレスポンスから判断できるようにすると同時に、削除イベントがリアルタイムにクライアントへ伝達される仕組みを導入する必要がある。
今回の新曲で、少しでもエンジニアのキューストファンが増えるといいなぁ。