APIs REST resolvem muito bem consultas, cadastros e integrações previsíveis. Mas alguns produtos precisam de uma conversa aberta entre cliente e servidor: chat, painel administrativo com eventos ao vivo, sala de votação, dashboard operacional, colaboração em documento, rastreamento de entrega, jogo simples ou notificação que não pode esperar o próximo refresh. Para esses cenários, WebSockets com Ktor e Kotlin são uma alternativa direta, idiomática e alinhada com coroutines.

Este tutorial mostra como criar um endpoint WebSocket no Ktor, receber mensagens, transmitir eventos para usuários conectados e preparar o caminho para produção. Se você ainda está montando a base do backend, comece pelo tutorial de Ktor para APIs REST e pelo guia completo de Ktor para backend. Aqui vamos focar na parte em tempo real.

Quando WebSocket faz sentido

WebSocket mantém uma conexão aberta e bidirecional. O cliente pode enviar mensagens a qualquer momento, e o servidor também pode empurrar eventos sem esperar uma nova requisição HTTP. Isso é diferente de polling, onde o cliente pergunta repetidamente “tem novidade?”, e diferente de Server-Sent Events, que costuma ser mais simples quando o fluxo é apenas servidor para cliente.

Use WebSocket quando você precisa de:

  • comunicação bidirecional com baixa latência;
  • eventos frequentes e pequenos;
  • presença de usuários conectados;
  • salas, canais ou tópicos compartilhados;
  • confirmação rápida de mensagens;
  • experiência interativa em navegador, Android ou desktop.

Evite WebSocket quando uma requisição REST resolve bem o problema. Manter conexões abertas tem custo operacional: timeout, autenticação, reconexão, balanceamento, observabilidade e limites por usuário precisam ser pensados desde cedo.

Dependências do projeto

Em um projeto Ktor com Gradle Kotlin DSL, adicione o plugin de WebSockets e serialização JSON:

plugins {
    kotlin("jvm") version "2.0.0"
    id("io.ktor.plugin") version "2.3.12"
    kotlin("plugin.serialization") version "2.0.0"
}

dependencies {
    implementation("io.ktor:ktor-server-core-jvm")
    implementation("io.ktor:ktor-server-netty-jvm")
    implementation("io.ktor:ktor-server-websockets-jvm")
    implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}

As versões acima são didáticas. Em projeto real, confira as versões atuais no catálogo do time e evite misturar versões incompatíveis de Kotlin, Ktor e kotlinx.serialization. Se você usa Version Catalog, veja também o artigo sobre Gradle Version Catalog em Kotlin.

Instalando o plugin WebSockets

No Ktor, WebSocket é um plugin instalado na aplicação. A configuração mínima define ping periódico e timeout para detectar conexões mortas:

import io.ktor.server.application.*
import io.ktor.server.plugins.websocket.*
import kotlin.time.Duration.Companion.seconds

fun Application.module() {
    install(WebSockets) {
        pingPeriod = 20.seconds
        timeout = 20.seconds
        maxFrameSize = Long.MAX_VALUE
        masking = false
    }

    configurarRotasTempoReal()
}

pingPeriod ajuda o servidor a perceber quando o cliente sumiu sem fechar a conexão corretamente, algo comum em redes móveis. timeout define quanto tempo esperar por resposta. Em produção, não deixe maxFrameSize infinito sem necessidade: limite o tamanho da mensagem para evitar abuso e consumo de memória.

Criando um endpoint de chat

O exemplo abaixo cria uma sala simples. Cada conexão recebe mensagens de texto e retransmite para todas as outras sessões conectadas. É um ponto de partida para entender o fluxo, não uma arquitetura final de produto.

import io.ktor.server.application.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import java.util.Collections

private val sessoes = Collections.synchronizedSet(mutableSetOf<DefaultWebSocketServerSession>())

fun Application.configurarRotasTempoReal() {
    routing {
        webSocket("/ws/chat") {
            sessoes += this

            try {
                send("Conectado ao chat Kotlin Brasil")

                for (frame in incoming) {
                    if (frame is Frame.Text) {
                        val texto = frame.readText().trim()

                        if (texto.isNotBlank()) {
                            transmitir("usuario: $texto")
                        }
                    }
                }
            } finally {
                sessoes -= this
            }
        }
    }
}

private suspend fun transmitir(mensagem: String) {
    sessoes.toList().forEach { sessao ->
        sessao.send(mensagem)
    }
}

A ideia principal é que incoming funciona como um fluxo de frames recebidos. Dentro do loop, você trata texto, binário, ping, pong ou close conforme o protocolo do seu produto. Para chat e notificações, texto com JSON costuma ser suficiente.

Use mensagens tipadas com kotlinx.serialization

Em vez de transmitir strings soltas, modele contrato. Isso facilita evolução, validação e testes:

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
sealed interface EventoChat {
    @Serializable
    @SerialName("mensagem")
    data class Mensagem(
        val usuario: String,
        val texto: String,
        val enviadaEm: Long,
    ) : EventoChat

    @Serializable
    @SerialName("presenca")
    data class Presenca(
        val usuario: String,
        val online: Boolean,
    ) : EventoChat
}

private val json = Json {
    ignoreUnknownKeys = true
    classDiscriminator = "tipo"
}

Com esse modelo, o cliente sabe se recebeu mensagem, presença ou outro evento futuro. Também fica mais fácil integrar com Android, Compose Multiplatform ou frontend web sem depender de parsing frágil.

Autenticação e salas

Não confie em usuario vindo do corpo da mensagem. Em produção, autentique antes de aceitar a conexão. Uma abordagem comum é enviar um token curto no header Authorization ou na query string apenas se o ambiente não permitir headers customizados. O servidor valida o token, identifica o usuário e decide quais salas ele pode acessar.

Um desenho mais realista separa responsabilidades:

  • ConnectionRegistry guarda conexões por usuário e sala;
  • ChatService valida comandos e aplica regras de negócio;
  • MessageRepository persiste histórico quando necessário;
  • EventPublisher publica eventos para outras instâncias do serviço.

Essa separação evita colocar regra de negócio diretamente dentro do bloco webSocket. O bloco deve orquestrar conexão, leitura, escrita e fechamento; a decisão de domínio deve ficar em serviços testáveis.

Cuidado com múltiplas instâncias

O exemplo com sessoes em memória funciona em uma única instância. Se você roda o Ktor em Kubernetes, Cloud Run, VPS com balanceador ou qualquer ambiente com réplicas, um usuário pode estar conectado na instância A enquanto outro está na instância B. Nesse caso, retransmitir apenas para a memória local não basta.

Para escalar, use um barramento externo: Redis Pub/Sub, Kafka, RabbitMQ, NATS ou outro sistema de mensageria. O endpoint WebSocket recebe a mensagem, valida, publica no barramento e cada instância repassa para suas conexões locais. O artigo sobre Kotlin com Kafka e RabbitMQ aprofunda esse tipo de arquitetura.

Também revise afinidade de sessão. Alguns balanceadores permitem sticky sessions, mas isso não substitui um barramento quando o produto precisa entregar eventos entre usuários conectados em réplicas diferentes.

Testes e observabilidade

Teste WebSocket em três camadas. Primeiro, teste serviços puros: validação de comando, permissões, formatação de evento e persistência. Depois, teste o endpoint Ktor com cliente WebSocket de teste. Por fim, rode um teste manual ou automatizado em ambiente de staging para cobrir proxy, TLS e timeout real.

Métricas úteis incluem:

  • conexões abertas por instância;
  • mensagens recebidas e enviadas por sala;
  • erros de parsing e autenticação;
  • tempo médio de conexão;
  • desconexões por timeout;
  • tamanho médio de payload.

Logs devem incluir identificador de usuário, sala e correlation id, mas nunca token, senha ou dados sensíveis. Para aprofundar essa disciplina, veja observabilidade em Kotlin e o guia de Ktor Client resiliente, que discute timeout, fallback e rastreabilidade em integrações HTTP.

Checklist antes de produção

Antes de publicar um WebSocket Ktor em produção, revise:

  1. Autenticação obrigatória e autorização por sala ou recurso.
  2. Limite de tamanho de mensagem.
  3. Ping, timeout e política de reconexão documentados para o cliente.
  4. Validação de JSON e rejeição de tipo desconhecido.
  5. Backpressure ou limite de fila por conexão lenta.
  6. Métricas de conexão, erro e throughput.
  7. Estratégia para múltiplas instâncias.
  8. Teste com proxy/TLS igual ao ambiente real.

Esse checklist evita a armadilha de tratar WebSocket como “apenas uma rota diferente”. Na prática, ele muda o ciclo de vida da conexão e cria uma superfície operacional nova.

Conclusão

Ktor torna WebSockets em Kotlin bastante acessíveis: instalar o plugin, criar uma rota webSocket, ler frames e enviar respostas é simples. O valor real aparece quando você combina essa simplicidade com contratos tipados, coroutines, autenticação, observabilidade e uma estratégia clara para escalar além de uma única instância.

Se seu projeto precisa de chat, presença, notificações ou eventos em tempo real, comece pequeno com uma sala simples, teste bem o protocolo e evolua para mensageria externa quando houver múltiplas réplicas. Esse caminho mantém o código Kotlin idiomático sem ignorar as exigências de produção.