How to Fix AWS API Gateway WebSocket Connection Fails ($connect, $disconnect,$default Routes)

Logeshwaran.C

A WebSocket API in API Gateway needs exactly three route keys defined — $connect, $disconnect, and $default — plus any custom route keys your app's messages use, matched through a route selection expression. Here's the part that wastes people's whole afternoon: those routes are almost always already there, spelled correctly, sitting in the console exactly where they should be. The actual reason the connection fails is almost never the routing table itself — it's a stage that was never redeployed after the routes were added, an IAM policy missing one specific action, or a Lambda authorizer that's silently never being invoked at all. 

⚡ Quick Answer

Define these route keys$connect, $disconnect, $default, plus your own custom keys (like sendmessage or joinroom) matched by the route selection expression, usually $request.body.action.

Then redeploy → API Gateway does not push route, integration, or authorizer changes live until you run Deploy API (or aws apigatewayv2 create-deployment) against the exact stage your client is connecting to.

If the routes exist and the API is deployed and it still won't connect, skip ahead to the authorization section — that's where most "phantom" failures actually live.

Jake found this out the expensive way. A customer walked into his phone shop wanting live trade-in quotes streamed to a kiosk screen while she waited — a WebSocket feed pulling prices as they updated. Jake built it in an afternoon, and the connection just... never opened. Not an error message. Not a crash. The browser console showed a closed socket and nothing else.

"I checked the routes four times," Jake told Ethan. "$connect, $disconnect, $default — they're all there. Why does it act like the API doesn't exist?" Ethan didn't even open the routes tab first. He asked to see the deployment history instead — and that's where this whole story is going to start too.

The three routes every WebSocket API needs — and the ones you add yourself

A WebSocket connection isn't one request-response like a normal web page load. It's a long-lived pipe that stays open, and messages travel both directions on it whenever either side feels like sending one. Because there's no URL path or HTTP method to route on the way there is with a REST API, API Gateway needs a different mechanism to decide what code runs when. That mechanism is the route key — a short string API Gateway matches against either a built-in event (connecting, disconnecting) or against a value inside the JSON message the client sent.

There are exactly three predefined route keys, and you can also define your own on top of them:

Route key When API Gateway calls it Integration required?
$connect While the client's WebSocket upgrade request is being processed, before the connection is actually established. Optional — but this is the only route where you can attach authorization.
$disconnect After the client or the server closes the connection. Optional — commonly used to remove the connection ID from storage.
$default When the route selection expression can't be evaluated, or evaluates to a route key that doesn't exist. Not required to exist, but if it's missing and nothing else matches, the client gets an error back.
Custom (e.g. sendmessage) When the route selection expression evaluates to a value that matches this route's key exactly. Required if you want the message to do anything.

An important, easy-to-miss detail: you cannot convert a REST API into a WebSocket API or vice versa after creation — the protocol type is fixed at creation. So if a connection is failing and you're wondering whether you accidentally built a REST API and are trying to speak WebSocket to it, that's worth ruling out early; the symptom looks similar (immediate rejection) but the fix is "build a new API," not "fix a route."

By default, the account-wide limit on resources or routes in a single REST or WebSocket API is 300, and this quota is adjustable if you genuinely need more. Almost nobody hits it with a normal chat or notification app, but it matters if you're generating routes dynamically per tenant or per feature flag.

The $connect route: where almost every real failure actually happens

$connect is the route API Gateway invokes while a client's WebSocket upgrade request is still pending — before the connection actually opens. This is the only route in the entire API where authorization can be configured, because a WebSocket connection is stateful: once it's open, API Gateway isn't re-checking credentials on every message the way a REST API re-checks them on every request. Whatever gate you want on who can connect at all has to live here.

You don't strictly need to attach an integration — a Lambda function, an HTTP backend, or an AWS service action — to $connect at all. Plenty of WebSocket APIs leave it with no integration and let every well-formed upgrade request through. But you should set one up if any of the following is true: you want to store the connection ID somewhere (DynamoDB is the common pattern) so you can push messages back later, you want to throttle or reject specific clients, you want clients to negotiate a subprotocol using the Sec-WebSocket-Protocol header, or you simply want to know when someone connects.

‍♂️ Jake's Reality Check

"If I don't put anything on $connect at all, will connecting just... work?"

Yes — API Gateway will complete the handshake with no integration attached to $connect at all. That's exactly why "the route is there" isn't proof anything is configured correctly on it. An empty $connect route accepts everyone, which is fine for a demo and a liability for anything real.

If the request to $connect fails — because the authorizer denied it, or the integration threw an error — the connection is never established at all, and the client gets a 401 or 403 response instead of an open socket. This is the single most common shape of "my WebSocket won't connect": nothing about the socket layer is broken, the handshake is simply being refused before it starts.

Non-proxy integration field What it captures
$context.connectionIdThe unique ID assigned to this connection — save this if you'll message the client later.
$context.domainNameThe API's domain, needed to build the callback URL for sending messages back.
$context.stageWhich stage the client connected through.

Ethan's take on this one is blunt: "People treat $connect like plumbing and $default like the interesting part. It's backwards. $connect is the only door in the whole building, and it's the only place a lock can go. Get that part right first."

The $disconnect route: cleanup, not prevention

API Gateway invokes $disconnect when the client or the server closes the connection. The most common integration here removes the saved connection ID from wherever $connect put it, so you're not trying to push a message to a socket that no longer exists.

Two honest limitations worth knowing before you rely on it: first, because the connection is already closing, an error thrown by your $disconnect integration can't be surfaced back to a client that's already gone — there's nothing on the other end to tell. Second, a client that vanishes ungracefully — a phone that loses signal, a laptop lid slammed shut mid-session — doesn't always trigger a clean close frame. API Gateway will eventually detect the connection is dead once it hits the idle timeout, and $disconnect fires then, but "eventually" can be up to 10 minutes later. If your app logic assumes a user is gone the instant $disconnect runs, build in tolerance for that lag.

Jake's shop has its own version of this. The store Wi-Fi router gets power-cycled every Monday morning when the cleaning crew unplugs it to run the vacuum — same time, every week, like clockwork. Every open kiosk connection dies at once, ungracefully, with no close frame at all. For a while Jake's dashboard just showed the same stale prices until someone noticed and refreshed the page. The fix wasn't anything on the AWS side; it was making the client itself detect "no message has arrived in a while" and reconnect proactively, instead of waiting to be told the connection was gone.

Custom routes and the route selection expression

Once a connection is open, every message a client sends is JSON (or it isn't — more on that in a moment), and API Gateway decides which integration to invoke by evaluating a route selection expression against that message. This expression is set once, at the API level, and the overwhelmingly common choice is $request.body.action — meaning API Gateway looks for a top-level action property in the JSON and uses its value as the route key to match against.

Say your chat app sends this:

{"action":"sendmessage","message":"Hello everyone"}

With $request.body.action as the expression, API Gateway evaluates to sendmessage and looks for a route with exactly that key. If you defined one, that route's integration runs. If you didn't, and a $default route exists, that runs instead. If neither exists, the service returns an error to the sender.

Here's how to set one up from scratch in the console:

  1. Open the API Gateway console, choose your WebSocket API, and go to Routes.
  2. Choose Create route, and for Route key enter the exact string your client will send as the value of the action field — case-sensitive, no extra whitespace.
  3. Attach an integration to the new route (Lambda is the common choice) the same way you would for $connect.
  4. Repeat for every action your client's messages can send.
  5. Make sure a $default route exists to catch anything unmatched, so senders get a controlled response instead of a hard failure.
  6. Redeploy the API to the stage your client uses — this is the step people skip, and it's covered in full below.

Two details catch people out here. Route keys are matched exactly and are case-sensitive, so sendMessage and sendmessage are different routes as far as API Gateway is concerned — a client-side typo or a casing mismatch between frontend and backend documentation will silently fall through to $default (or to an error, if there's no $default) rather than throwing an obvious "route not found" you can Google. And second: only JSON messages can be routed by content at all. A message that isn't valid JSON is passed straight through to $default regardless of what it contains, because there's no property for the expression to evaluate.

The $default route: your safety net for everything else

API Gateway calls $default in exactly two situations: the route selection expression couldn't be evaluated at all (typically because the message wasn't valid JSON, or the expected property was missing), or it evaluated to a value that doesn't match any defined route. If no $default route exists and neither of those recovery paths finds a home, API Gateway returns an error to whoever sent the message.

✅ Why this is the one to define, every time

Even if your app only ever sends one kind of message, add a $default route with a mock or minimal integration that returns something like {"message":"Unrecognized action"}. It costs almost nothing, and it turns "the client got a cryptic connection error" into "the client got a message it can log and show the user." That difference is the whole ballgame when you're debugging a report from someone else's browser you can't see.

You can also use $default for something more deliberate: a mock integration that tells clients which route they should actually be using. AWS's own Step Functions WebSocket tutorial does exactly this — the $default route returns a message instructing the client to use the real route (in that tutorial, a sendmessage route) instead of whatever it just sent.

The deployment trap: why routes that exist still don't work

This is the callback to the reveal at the top, and it's the one thing worth remembering above everything else in this post: every change you make to routes, integrations, or authorizers requires a fresh deployment before it takes effect. Changing stage settings — throttling, logging level, stage variables — applies immediately. Everything else does not. You create a deployment, and you associate that deployment with a stage; the URL clients actually connect to is tied to the stage, not to your unsaved changes in the console.

This is precisely what happened to Jake. He'd added his custom routes in the console, tested the integration with the built-in "Test" button (which calls the Lambda directly and bypasses the whole routing question), saw a green checkmark, and assumed he was done. He wasn't — the stage his kiosk was pointed at was still running the deployment from before those routes existed. Nothing was wrong with a single route definition. The live snapshot the client was actually hitting simply predated them.

⚠️ What this actually breaks

This isn't limited to custom routes — it applies to $connect, $disconnect, and any authorizer changes too. Attach a Lambda authorizer to $connect, forget to redeploy, and clients will keep connecting with the old (or nonexistent) authorization behavior until you do. Infrastructure-as-code tools like CDK and Terraform usually handle this for you by creating a new deployment resource whenever something changes — but only if their change-detection actually notices the change. A route added by hand in the console, outside your IaC pipeline, is a classic way to end up with a deployment that's stale relative to what you think is configured.

To deploy manually in the console: open your API, choose Deploy API, pick the stage from the dropdown (or type a new stage name), and choose Deploy. From the CLI, it's a two-step dance — create a deployment, then either create a new stage pointed at it or update an existing stage's deploymentId to that new deployment. By default you're limited to 10 stages per API, which is generous enough that reusing a small, deliberate set of stage names (dev, staging, production) is the recommended pattern rather than minting a new one for every change.

IAM and Lambda authorizer failures that look like broken routes

Once the routes exist and the API is deployed, the next place connections quietly die is authorization on $connect. There are two mechanisms, and they fail differently.

IAM authorization. WebSocket IAM authorization looks like REST API IAM authorization with two extras: the execute-api action set gains ManageConnections alongside the familiar Invoke and InvalidateCache, and WebSocket routes use their own ARN shape — arn:aws:execute-api:region:account-id:api-id/stage-name/route-key — while the @connections management API keeps the REST-style ARN, arn:aws:execute-api:region:account-id:api-id/stage-name/POST/@connections. Two IAM mistakes cause almost all of the confusion: granting execute-api:Invoke but forgetting execute-api:ManageConnections for whatever caller needs to push messages back to clients through @connections, and requests that aren't signed with Signature Version 4 at all — SigV4 signing is mandatory the instant IAM authorization is turned on for a route, and an unsigned request is rejected outright.

A related and separate failure shows up not at connect time but when your backend tries to message a client: a ForbiddenException on PostToConnection even with what looks like correct IAM permissions in place is very often caused by initializing the API Gateway Management API client against the wrong endpoint — it needs to point at the WebSocket's own callback URL (https://{api-id}.execute-api.{region}.amazonaws.com/{stage}), not a generic regional endpoint. Get that one detail wrong and no amount of IAM policy tweaking fixes it.

Lambda authorizers. For WebSocket APIs, a Lambda authorizer is only ever attached to $connect, because that's the only point where a stateful connection can be authorized at all. Two things go wrong here constantly. First, the authorizer sometimes simply never runs — API Gateway only invokes it if you've configured an identity source (a header or a query string parameter the client is expected to send), and if a client's request doesn't include whatever you declared as that identity source, the authorizer is skipped entirely rather than failing loudly. Second, even when the authorizer runs and returns an explicit allow, you can still get a 403 with a logged message like "The client is not authorized to perform this operation" if the IAM policy document your authorizer returns doesn't actually grant execute-api:Invoke on the resource ARN API Gateway is checking against. An allow from your own business logic and an allow in the returned IAM policy are two different things, and only the second one matters to API Gateway.

‍♂️ Jake's Reality Check

"My Lambda authorizer runs fine when I test it directly and clearly returns allow. Why is the client still getting rejected?"

Test the function in isolation and you're only checking that your code runs, not that API Gateway accepts what it returned. Look at the actual policy document the authorizer hands back, and confirm the resource ARN in it matches the route API Gateway is evaluating — not a route from a different stage, not a typo'd API ID.

Custom domains, CloudFront, and the endpoint type trap

When you build a WebSocket API in the console, the choice of endpoint type is made for you: only Regional endpoints are supported. That single fact quietly explains a surprisingly common support thread where someone puts a hand-built CloudFront distribution in front of their WebSocket API — often to get a custom domain or to attach AWS WAF — and afterward every connection attempt just keeps retrying and getting back an HTTP 200 instead of upgrading to a socket. A generic reverse proxy in front of a WebSocket endpoint has to explicitly forward the Upgrade and Connection headers and be configured to allow the protocol switch; if it isn't, it happily returns a normal 200 HTTP response because, as far as it's concerned, nothing asked it to do anything unusual.

⚠️ What this actually breaks

If you need a custom domain for a WebSocket API and you're not intentionally building a CloudFront layer yourself, use API Gateway's native custom domain name support against the Regional endpoint rather than improvising a CloudFront distribution as a workaround. A homemade proxy that doesn't understand the WebSocket upgrade handshake is one of the harder failures to diagnose, because the client-side error is often just "connection closed" with nothing more specific.

If you do genuinely need WAF in front of a WebSocket API, remember that protection realistically only matters at the point of connection — WAF rules attach to $connect, since that's the one moment where a request can still be evaluated and rejected before a stateful connection exists. An API key with a usage plan on $connect is the lighter-weight option most teams reach for first, before reaching for WAF at all.

Private integrations, VPC links, and other backend setups

Not every backend behind a WebSocket route is a Lambda function. API Gateway also supports routing to a private endpoint inside a VPC — an EC2 instance, an ECS service, anything sitting behind a Network Load Balancer — using a VPC link, the same mechanism REST APIs use for private integrations. Integration requests to the VPC link work the same way they do for REST APIs, so the general design rules carry over, with one WebSocket-specific gotcha worth knowing before you build anything on top of it.

A WebSocket API does not automatically pass the connection ID through to a VPC link integration. Since the connection ID is exactly what your backend needs to send a callback response later through @connections, forgetting this step produces a very specific failure shape: the connection opens fine, messages arrive at your backend fine, but your backend has no way to reply to the right client because it was never handed a connection ID to reply to. The fix is to explicitly map context.connectionId onto a request parameter — typically a custom header — on the integration itself, then redeploy. This is configured through the integration's request parameters, not through anything on the route.

✅ Naming what we cannot do

If your backend is stateless containers behind a load balancer rather than Lambda, don't assume connection tracking is free. You still need somewhere durable — DynamoDB is the usual choice — to map connection IDs to whatever session or user context your app cares about, exactly as you would with a Lambda-based backend. A VPC link changes where your code runs; it doesn't change what state you're responsible for keeping.

On the client side, none of this changes based on what's connecting. A browser tab, a native mobile app, a headless server-side client, a Postman WebSocket request, or a script running inside a container all speak the same protocol and hit the same $connect route the same way — the WebSocket handshake itself doesn't care what's on the other end. Where they do differ is reconnection behavior after a network change: a mobile client switching from Wi-Fi to cellular, or a laptop waking from sleep, will typically see its old connection simply go stale rather than receive a clean close frame, which is another reason a client-side "no traffic in the last few minutes, reconnect" heartbeat is worth building regardless of backend architecture.

The size and rate limits that will end your connection anyway

Sometimes the connection opens fine and the failure shows up later, and it's worth knowing these numbers cold before you go looking for a bug that isn't one — none of the four below can currently be increased:

Limit Value Adjustable?
Idle connection timeout10 minutes with no traffic in either directionNo
Maximum connection duration2 hours (7,200 seconds), even if the connection is active the whole timeNo
WebSocket frame size32 KBNo
Message payload size128 KB (must be split into 32 KB frames if larger)No
New connections per second, per account, per region500, across all your WebSocket APIsYes
Integration timeout50 ms – 29 seconds by default, for any integration typeYes, for Regional and private APIs

✅ Naming a limit AWS says plainly it will not remove

AWS has stated directly, in response to customer requests, that the 10-minute idle timeout and 2-hour connection ceiling exist to conserve server resources and keep the service scalable, and that raising them isn't planned — at most a shorter, configurable idle window has been floated. If your use case needs connections that genuinely outlive 2 hours without a client-driven reconnect, that's a real architectural constraint to design around now, not a support ticket to file later.

Binary media types aren't supported on WebSocket APIs at all — every message is treated as text, so audio, images, or any binary payload has to be base64-encoded (or otherwise converted to text) by the client before sending. If you need more than 29 seconds of backend processing time per message, the fix isn't a bigger timeout setting — it's decoupling the work with something like Amazon SQS, so the WebSocket route hands off the job and responds quickly while the actual processing happens asynchronously and pushes a result back later through @connections.

Worth naming plainly, since it's easy to overlook until a bill shows up: the 500 new-connections-per-second account limit and the account-level throttle rate quota both apply across every WebSocket API you run in a region, not per API. An unrelated app spiking its connection rate can eat into headroom you assumed was reserved for something else. If you're running more than one meaningful WebSocket workload in the same account and region, this is worth a request to raise the adjustable connection-rate quota ahead of time rather than discovering the shared ceiling during a launch.

Testing your WebSocket API properly, before you blame anything else

A huge amount of "my WebSocket won't connect" debugging happens against a client application that has its own bugs layered on top of whatever API Gateway is doing. Before touching routes, authorizers, or IAM policies again, isolate API Gateway from your client code entirely:

  1. Copy your API's invoke URL from the Stages page in the console — it follows the pattern wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}. A wrong API ID, region, or stage name here produces a connection failure that has nothing to do with your routes at all.
  2. Connect with wscat from a terminal: wscat -c wss://your-invoke-url. If it says "Connected," your $connect route, authorization, and deployment are all working — anything wrong from here on is in your custom routes or your client code, not your infrastructure.
  3. Send a test message by hand in the same terminal session, formatted exactly like your app would send it, e.g. {"action":"sendmessage","message":"test"}, and watch what comes back.
  4. Turn on execution logging on the stage if the behavior still isn't clear — set the stage's logging level to INFO. If this is the first time you've enabled CloudWatch logging anywhere in this account and region, API Gateway will refuse with an error to the effect of "CloudWatch Logs role ARN must be set in account settings to enable logging" until you create an IAM role trusted by apigateway.amazonaws.com, attach the AWS-managed AmazonAPIGatewayPushToCloudWatchLogs policy to it, and set that role's ARN as the account's CloudWatch Logs role — a one-time, per-region setup step that's easy to forget about because it isn't part of any single API's configuration.
  5. Read the resulting CloudWatch Logs entry for the failed request — for authorization failures specifically, it will usually name the exact reason (missing identity source, denied by policy, authorizer error) rather than leaving you to guess from the client-side close code alone.

This sequence matters because it separates two very different categories of bug in minutes instead of hours: "API Gateway is rejecting the connection" versus "my frontend JavaScript is doing something wrong before it even gets that far." Ethan's rule of thumb: "If wscat connects and your app doesn't, stop looking at AWS. The problem just moved to your code."

Catching the deployment trap automatically, before a customer does

Since the deployment trap covered earlier is so often the actual cause, it's worth building a habit — or a script — around checking for it rather than re-learning the lesson every time a route changes. Two AWS CLI commands, run together, tell you whether a stage is genuinely current: aws apigatewayv2 get-deployments --api-id your-api-id lists every deployment snapshot that exists, most recent included, and aws apigatewayv2 get-stage --api-id your-api-id --stage-name your-stage shows which deploymentId that stage is actually running. If the newest deployment's ID doesn't match the stage's current deploymentId, you have your answer before you've opened a single route in the console.

This pairs naturally with a pre-deploy or CI step: after any change to routes, integrations, or authorizers, run a deployment, then update the target stage's deploymentId to match it, and only then consider the change "live." Teams using CDK or Terraform generally get this for free, since both track a hash of the API's configuration and create a new deployment when it changes — but it's worth confirming that behavior explicitly rather than assuming it, especially in a codebase where routes have ever been added by hand through the console outside the usual pipeline. A five-minute script that fails a build when the stage is stale is considerably cheaper than a customer telling you the kiosk stopped updating.

Reading the close code: what each failure actually tells you

When a connection closes, the WebSocket status code your client receives tells you a lot, if you know how to read it:

Code What it means What to do about it
1001Idle for 10 minutes, or hit the 2-hour maximum lifetime.Reconnect rather than diagnose — both situations use this same code, and both are expected behavior, not bugs.
1003A binary media type was sent — not supported on WebSocket APIs.Encode binary payloads as text (base64) before sending.
1005The client sent a close frame with no closure code at all.Check the client library's close handling — this is usually client-side, not API Gateway.
401 / 403 (on the handshake, not a close code)Authorization failed on $connect before the connection was ever established.Go to the IAM/Lambda authorizer section above — this is never a routing problem.

Separately from close codes, a 410 GoneException when your backend tries to send a message to a client (via PostToConnection) means the connectionId you're targeting no longer refers to a live connection — the message arrived before the connection finished establishing, the connection has already been terminated, or the client reconnected and got issued a brand-new connectionId that your stored copy hasn't caught up to. CloudWatch execution logs at INFO level are the documented way to confirm which of those actually happened rather than guessing.

What to check when the "obvious" fix doesn't work

If you've confirmed the routes exist, redeployed the API, checked the invoke URL is exactly right, and it still won't connect, work through these in order:

You redeployed, but a different stage than the one your client uses. A stage is a named, independent snapshot; deploying to "dev" does nothing for a client hardcoded to "production." Check the invoke URL's stage segment against the stage you actually deployed to — not the one you meant to.

Your Lambda authorizer's IAM policy resource ARN is scoped to the wrong stage or route. A policy built for the "dev" stage's $connect route doesn't automatically extend to "production," even if the underlying Lambda code and logic are identical. This produces the exact same denial whether the authorizer logic is correct or not, because API Gateway is checking the returned ARN, not your intent.

Your route selection expression property name doesn't match your client's payload. If the expression is $request.body.action and your client is sending {"type":"sendmessage"} instead of {"action":"sendmessage"}, the expression evaluates to nothing usable, and the message falls to $default — or errors, if there's no $default. This is invisible from the client side because the socket itself stayed open; only the specific message silently misroutes.

You're testing with the console's built-in Test feature and assuming it proves the live path works. Testing an integration directly in the console calls your backend function or service in isolation — it does not go through the route selection expression, the deployed stage, or any authorizer. A green checkmark there tells you your Lambda function runs; it tells you nothing about whether a real client can reach it.

Your VPC link integration never received the connection ID. If replies to clients through a private, non-Lambda backend seem to vanish even though the connection is open and messages are arriving, revisit whether context.connectionId was actually mapped onto a request parameter for that integration — it isn't passed through automatically.

✅ Saying the popular advice is often wrong

The most repeated advice for a failing WebSocket connection is "check your routes." It's not wrong exactly — it's just rarely where the actual fault sits. In practice the routing table is the part of this system that's hardest to get subtly wrong (route keys are either an exact string match or they aren't), while deployment state, IAM scoping, and authorizer identity sources are all easy to get subtly, silently wrong. Start with deployment and authorization before you spend another hour re-reading route keys that were correct the whole time.

A short checklist before you call this production-ready

Jake's kiosk eventually worked — and once it did, Ethan made him go back through this list before letting the customer near it again, because "it connects on my laptop" and "it survives a Saturday afternoon of foot traffic" are different bars entirely.

Reconnection logic on the client. Every connection dies eventually — idle timeout, the 2-hour ceiling, a deploy, a network blip like the shop's Monday-morning router reset. A client that treats any close as fatal instead of reconnecting will feel broken to real users even when the backend is behaving exactly as documented.

A real $connect authorization strategy. An open $connect route is fine for a prototype and a liability the moment real data or real cost is on the other end of it. Decide deliberately between IAM authorization, a Lambda authorizer, or an API key with a usage plan — don't leave it unset by default.

Connection storage that survives $disconnect lag. Because ungraceful disconnects can take up to 10 minutes to be detected, code that pushes messages to "everyone currently connected" should handle a stale connectionId returning 410 Gone gracefully — treat it as a cleanup signal, not an application error.

CloudWatch logging turned on, deliberately, before you need it. Set up the account-level CloudWatch role once, ahead of time, in every region you deploy to — not during an outage, when you discover the setting doesn't exist yet and now have to create IAM roles under pressure.

Headroom against the shared connection-rate quota. If more than one WebSocket workload shares an account and region, confirm they aren't quietly competing for the same 500-connections-per-second ceiling before a busy day makes that visible for the first time.

A named strategy for connections longer than 2 hours. If your use case genuinely needs that (a long-running dashboard, a monitoring feed), plan for scheduled, transparent reconnects on the client rather than treating the ceiling as a bug to work around.

Frequently asked questions

What are the required routes for a WebSocket API in API Gateway?

Three predefined route keys exist for every WebSocket API: $connect, $disconnect, and $default. None of them are strictly mandatory to attach an integration to, but $connect and $disconnect always exist as concepts even if you don't configure anything on them — API Gateway will still complete or close the handshake. Custom route keys you define yourself handle everything your app actually does after the connection opens.

Do I have to set up an integration for the $connect route?

No. An integration on $connect is optional. Set one up if you need authorization, need to store the connection ID, want to be notified of new connections, or want to throttle who can connect. Without one, every valid upgrade request is accepted.

Why does my WebSocket connection fail with a 403 error?

A 403 on the handshake means authorization on $connect denied the request — either IAM authorization rejected an unsigned or improperly scoped request, or a Lambda authorizer returned a deny (or an allow policy whose resource ARN doesn't actually match). It is never caused by a missing custom route, since custom routes are only evaluated after the connection is already open.

Why does my WebSocket connection fail with a 500 or 502 error?

This points to a failure inside the $connect integration itself — a Lambda function throwing an unhandled error, a non-proxy mapping template that's malformed, or a backend the integration calls out to timing out or erroring. Turn on CloudWatch execution logging at INFO level and check the corresponding Lambda function's logs for the actual exception.

What does WebSocket close code 1001 mean?

Code 1001 covers two separate situations with the same code: the connection sat idle for 10 minutes with no traffic in either direction, or it reached the hard 2-hour maximum connection duration. Neither is a bug — the correct client behavior is simply to reconnect.

What does close code 1006 mean and why is it hard to diagnose?

Close code 1006 in the WebSocket standard generally represents an abnormal closure with no proper close frame at all, which is exactly why it's frustrating: the connection just vanishes without a documented reason attached. When you see it against an API Gateway WebSocket API, treat it as a signal to check CloudWatch execution logs immediately rather than trying to reason from the client side alone, since the client genuinely may not have received any explanation.

Can I send binary data over an API Gateway WebSocket API?

No. Binary media types are not supported on WebSocket APIs — a binary frame produces a 1003 close code. If you need to send images, audio, or other binary content, encode it as text (base64 is the common approach) on the client before sending, and decode it on the receiving end.

Why do I get a 410 GoneException when sending a message to a client?

A 410 GoneException from PostToConnection means the connectionId you targeted doesn't refer to a live connection anymore — the message was sent before the connection finished establishing, the connection has since closed, or the client disconnected and reconnected under a new connectionId that your stored records haven't been updated with. Treat it as a cue to remove that connectionId from storage, not as an error to retry.

Do I need to deploy my WebSocket API after adding a route?

Yes, every time. Any change to routes, integrations, or authorizers requires a new deployment associated with the stage your clients call before it has any effect. Stage-only settings, like throttling or logging level, apply immediately without a redeployment.

Can I use a Lambda authorizer on every route, or just $connect?

Only $connect. Because a WebSocket connection is stateful, authorization is checked once, at connection time — there's no per-message re-authorization the way REST APIs can check each request independently.

Why does my Lambda authorizer never get invoked?

A Lambda authorizer only runs when the request includes whatever you configured as its identity source — typically a specific header or query string parameter. If a client's connection request doesn't include that identity source, API Gateway skips invoking the authorizer entirely rather than failing loudly, which can look exactly like the authorizer silently allowing everything through when it isn't running at all.

Can I put a WebSocket API behind CloudFront?

WebSocket APIs in API Gateway only support Regional endpoints, not edge-optimized ones. You can still front a Regional WebSocket API with your own CloudFront distribution for a custom domain or WAF, but the distribution must be explicitly configured to forward the Upgrade and Connection headers involved in the WebSocket handshake — a generic proxy configuration will instead return a plain HTTP 200 and never actually establish the socket.

Why does my WebSocket API return 403 immediately with no logs at all?

If CloudWatch execution logging hasn't been enabled on the stage, or the account-level CloudWatch Logs role hasn't been set for that region, you won't see anything at all beyond the client-side error. Enable INFO-level logging on the stage, and if you're prompted that a CloudWatch Logs role ARN must be set in account settings, that's a one-time, per-region IAM role you need to create before logs will start appearing.

Can a private, non-Lambda backend receive WebSocket connections directly?

Yes, through a VPC link, the same private-integration mechanism REST APIs use to reach a Network Load Balancer in front of EC2 instances or ECS services. The one WebSocket-specific detail to plan for is that the connection ID isn't passed to the integration automatically — you have to map it onto a request parameter yourself, or your backend will never be able to reply to the right client.

What is the maximum WebSocket connection duration and can it be extended?

The maximum is 2 hours (7,200 seconds) per connection, and it is not adjustable — this applies regardless of whether the connection stayed active the entire time. Combine that with the separate, also fixed, 10-minute idle timeout, and design your client to reconnect transparently rather than assuming a session can stay open indefinitely.

How do I test a WebSocket API without building a client app?

Use wscat from a terminal against your stage's invoke URL (wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}). If it connects, your $connect route, deployment, and authorization are all working correctly, and any remaining issue is isolated to your custom routes or your actual client application.

Why do my custom routes stop matching after I add a new one?

This is almost always the deployment trap again — a newly added route exists in the API's configuration but hasn't been pushed to the stage your client calls yet. It can also happen if the new route's key collides in casing or spelling with an existing one, since route keys are matched as exact, case-sensitive strings, not a fuzzy or normalized comparison.

Revision note. Written September 2026, covering current API Gateway WebSocket API behavior for routes, deployments, IAM authorization, Lambda authorizers, VPC link private integrations, and the documented WebSocket connection quotas. This will need a revisit if AWS changes the idle timeout or connection duration limits, which the service team has said they're open to reconsidering as per public. If you've been stuck on a silent connection failure for a while now, you're not missing something obvious — this is a genuinely easy system to get subtly wrong, and we hope this got you unstuck.

Related