The type + data envelope
WebSocket gives you a stream of opaque messages. Adding structure is your job. The dominant pattern across the industry — Slack, Discord, GraphQL-WS, every chat library — is a small JSON envelope with a type string and a data object. The type field tells you which schema the data follows. Everything else falls out from this single decision.
Namespacing types
Use dot-notation: chat.message, chat.typing, user.joined, user.status, room.join, game.move. The first segment is the domain; the second is the verb or event. Stays sortable, greppable, and natural to extend.
Errors are messages too
An error response is just another message: {type: 'error', code: 'rate_limited', message: '...', ref_id: 'xyz'}. Same envelope, different schema. Treat errors as first-class, not as a special transport-level concept.