~/lxcidfollow

← writing/

Diagnosing H12 and H15 errors with Socket.IO on Heroku

By Stan Chang · · Updated · 6 min read · #web-development

Diagnosing H12 and H15 errors with Socket.IO on Heroku

What I learned in 2017, updated against current Heroku and Socket.IO documentation.

Short answer: Heroku H12 and H15 are different failures. H12 means the app did not start a response within 30 seconds. After a response begins, Heroku applies a rolling 55-second inactivity window: H15 means the router received no data from the dyno. Socket.IO 4’s default heartbeat already sends a server ping every 25 seconds and waits 20 seconds for the pong, so arbitrary heartbeat tuning is not a universal fix.

I first wrote this post in 2017 after my Socket.IO app started filling the Heroku logs with H12 and H15 errors. The configuration at the end fixed that deployment, but the original post blurred two router failures together and made a version-specific workaround sound universal.

Here is how I would diagnose it now.

Start with the Heroku router log

Tail the logs and keep the complete router line—the error code, path, dyno, service time, and status are more useful than the disconnect symptom alone.

heroku logs --tail

If the failing path is /socket.io/, confirm the active transport and correlate the router entry with Socket.IO’s client and server disconnect reasons. If it is another path, debug it as an ordinary slow request rather than changing Socket.IO settings.

H12: the initial 30-second request timeout

Heroku emits H12 when an HTTP request takes longer than 30 seconds to produce response data. The router returns an error to the client, but the dyno can continue doing the work unless the application enforces its own shorter timeout.

HTTP long-polling uses repeated HTTP requests, but long-polling is not itself proof that Socket.IO caused the H12. A slow handler, a blocked event loop, request queueing, an overloaded dyno, or a slow dependency can all consume the initial 30-second window. Heroku’s current guidance is to keep application-level request timeouts below the router limit and move genuinely long work to background jobs.

Heartbeat settings do not repair a handler that never starts its response. Start with the path and service time in the router log, then profile the work or capacity behind that request.

H15: the rolling 55-second idle connection timeout

After response data starts flowing, Heroku maintains a rolling 55-second inactivity window. H15 means the router received no data from the dyno during that window.

For a healthy Socket.IO 4 connection, the defaults are already shorter than Heroku’s idle window: the server sends a ping every 25 seconds and waits up to 20 seconds for the client’s pong. The 45-second sum controls when Socket.IO considers the connection dead. It is also relevant when configuring an upstream proxy timeout, but it is not a general rule that fixes every Heroku H15.

This is the current default-equivalent configuration, shown to make the values explicit. You normally do not need to set it:

import { Server } from "socket.io";

const io = new Server(httpServer, {
  pingInterval: 25_000,
  pingTimeout: 20_000,
});

If you have overridden either value, confirm that the server still sends traffic comfortably inside Heroku’s idle window. Also check whether a proxy or CDN between the browser and Heroku closes the connection first. Socket.IO’s troubleshooting guide says an upstream read timeout must be greater than pingInterval + pingTimeout; a regular transport close interval often points to that intermediate timeout.

Long-polling, WebSocket upgrades, and sticky sessions

Socket.IO normally starts with HTTP long-polling and then attempts to upgrade to WebSocket. That fallback improves reachability through restrictive networks, so disabling polling should be a deliberate tradeoff, not the first timeout fix.

When polling is enabled across multiple dynos, successive requests in one Socket.IO session must reach the same server. Socket.IO therefore requires sticky sessions, and Heroku offers session affinity for Common Runtime apps. A multi-node Socket.IO deployment also needs a compatible adapter if events must reach clients connected to different nodes.

WebSocket-only mode removes the repeated polling requests and therefore the sticky-session requirement, but it also removes the polling fallback:

const socket = io({
  transports: ["websocket"],
});

Use that only when your clients and network path can reliably establish WebSocket connections.

Troubleshooting checklist

Symptom Heroku or client signal Likely cause Socket.IO check Next action
Request fails at about 30 seconds H12 No initial response data; slow work, queueing, or blocked server Is the path /socket.io/? Did the handshake respond? Profile the request, add an application timeout below 30 seconds, or move long work to a background job
Established connection drops around 55 seconds H15 No data from the dyno during Heroku’s idle window Check the active transport, custom pingInterval, event-loop stalls, and disconnect reason Restore sensible heartbeat defaults and inspect the server or intermediary that stopped traffic
Disconnect repeats at a fixed interval, often 45–60 seconds transport close, sometimes no Heroku code Proxy, CDN, or load balancer timeout Compare intermediary timeout with pingInterval + pingTimeout Set the intermediary timeout above Socket.IO’s failure-detection window
Polling works on one dyno but fails after scaling Session/transport errors Polling requests reach different dynos, or nodes do not share events Confirm sticky sessions and the adapter Enable Heroku session affinity where supported and configure a multi-node adapter, or knowingly choose WebSocket-only mode

What my 2017 workaround actually proved

In 2017, I changed the server heartbeat to a 15-second interval and a 30-second timeout, then forced the client to use WebSocket:

// Historical 2017 configuration from the original post
import SocketIO from "socket.io";

const sio = SocketIO(server, {
  pingInterval: 15000,
  pingTimeout: 30000,
});

const socket = io({
  transports: ["websocket"],
});

That removed the H12 and H15 errors I was seeing. I did not record the exact Socket.IO version or isolate which change mattered, so it does not prove that pingInterval + pingTimeout < 55 seconds is a universal Heroku requirement. Socket.IO’s defaults and heartbeat implementation have changed across major versions; version 4 now has a server-driven heartbeat and defaults to 25 seconds plus 20 seconds.

Today I would begin with the router code, request path, transport, Socket.IO version, and disconnect reason. I would tune heartbeats only after finding evidence that the current values—or an intermediary timeout—are actually causing the failure.

Sources reviewed

If you discover a reproducible Heroku/Socket.IO edge case that these diagnostics miss, I would still love to hear about it.