# GitHub Webhook Timed Out? Why Your Host Drops Deliveries

> GitHub waits 10 seconds, marks the delivery "Timed out" and never retries. How sleeping apps, serverless limits and deploys lose webhooks, and the fix.

1. [Home](/)
2. [Blog](/blog)
3. GitHub Webhook "Timed out": Why Your Host Drops Deliveries

DeploymentSeptember 29, 20269 min read

# GitHub Webhook "Timed out": Why Your Host Drops Deliveries

GitHub gives your endpoint 10 seconds and never retries on its own. Where those seconds go on a sleeping free tier, a serverless function or a deploy, and how to build a receiver that doesn't lose events.

## The short version

GitHub expects a 2xx response within 10 seconds. If it doesn't get one, it closes the connection, marks the delivery "Timed out" and does not try again. A receiver that sleeps, restarts during deploys or does the work inside the request will lose events. The fix is an always-on endpoint that replies straight away and leaves the work to a queue.

[Teo Marquardt](/about#author)· Facts last checked September 29, 2026

The Recent Deliveries tab on your repository's webhook settings is full of red, and your application logs show nothing. No errors, no stack traces, not even a request. From GitHub's side the delivery failed. From your app's side it never happened. That gap almost always comes from where the receiver runs, not from the code that handles the payload.

This article is about webhooks that GitHub sends to your server: push, pull request, release and the rest. If you arrived here from a Kubernetes admission webhook timing out, that is a different mechanism with a similar name, and none of what follows applies to it.

## GitHub webhook timeout: "Timed out" means GitHub waited 10 seconds and hung up

A GitHub webhook times out when your server doesn't return a 2xx status within 10 seconds. GitHub's own guidance is short: [your server should respond with a 2XX response within 10 seconds](https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks) of receiving a delivery. If it takes longer, GitHub terminates the connection and records the delivery as a failure. In the delivery log that shows up as `Timed out`, and in some views as "timed out after 10s".

Ten seconds sounds generous for returning a status code, and for the handler alone it is. The catch is that those seconds cover more than your handler. They include resolving and reaching your host, the TLS handshake, any proxy or load balancer in front of the app, the app itself becoming ready if it wasn't, and only then your code. On a warm, always-on service the overhead is a few milliseconds. On some hosting setups it is most of the budget before your first line runs.

## Does GitHub retry failed webhook deliveries? No

No. [GitHub does not automatically redeliver failed webhook deliveries](https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries), so a delivery that ended in `Timed out` or `Failed to connect to host` is not tried again. Stripe and Shopify retry a failed webhook for hours or days with backoff. GitHub doesn't, and that is what turns a slow endpoint into lost data.

What you get instead is manual redelivery. You can resend any delivery [from the past three days](https://docs.github.com/en/webhooks/testing-and-troubleshooting-webhooks/redelivering-webhooks), either with the Redeliver button in the web interface or through the REST API, which exists for repository, organisation and GitHub App webhooks. After three days the delivery can't be recovered from GitHub at all.

A failed delivery is permanent unless you act

One timeout on a Friday evening, noticed on Tuesday, is an event you will never get back from GitHub. Nothing alerts you by default. The only record is the red entry in Recent Deliveries.

For a walkthrough of how deliveries, headers and redelivery work on GitHub's side, including the event types and payload limits, see this [complete guide to GitHub webhook events, payloads and redelivery](https://webhooker.eu/blog/github-webhooks-guide). This article stays on the hosting side of the problem.

## Four places a host eats your 10 seconds

### A free tier that went to sleep

Many free hosting tiers stop a container after about 15 minutes without traffic, and the next request waits for it to start again. Render's own dashboard warns that a sleeping free instance can delay requests by 50 seconds or more; the mechanics are in [what cold starts are and why free tiers sleep](/blog/cold-starts). GitHub will not wait 50 seconds. It will not wait 11.

What makes this failure hard to spot is its pattern. A repository with steady activity keeps the receiver awake, so deliveries during a busy afternoon all succeed. The first push after lunch, or the first on a Monday, arrives at a stopped container, times out, and is gone. The request that woke the app never reached your code, so the logs are clean. The next push works fine because the container is now running. It looks random, and it isn't.

### Serverless functions: a cold start plus a time limit

A function platform has the same wake-up cost when a function hasn't run recently, usually shorter than a container's but not zero. It also adds a second constraint: an execution cap. Handlers that verify the payload, then call the GitHub API, then write to a database, can hit the platform's limit on a slow day and be killed mid-request.

There is a subtler problem on many function runtimes. Once the response is sent, the runtime is free to freeze or discard the instance, so work you start "in the background" after replying may never finish. The delivery shows as successful in GitHub and the job silently doesn't happen, which is worse than a red entry you can at least see.

### The deploy window: why you get "Failed to connect to host"

A receiver that runs as a single instance and is restarted on every deploy has a few seconds, sometimes more, in which nothing is listening. A delivery that lands in that window doesn't time out. It fails outright, and GitHub reports it as one of these:

* `Failed to connect to network`: your server actively refused the connection, which is what a host with no process on the port does.
* `Failed to connect to host`: GitHub couldn't resolve the hostname or reach it at all, for example during DNS changes or behind a firewall that blocks it.
* `Invalid HTTP response`: something answered with a 4xx or 5xx, which during a deploy is usually the platform's proxy returning 502 or 503 because no healthy instance is behind it yet.

Pushes trigger deploys, and deploys restart the receiver, so this window is aligned with exactly the moments when GitHub is most likely to be sending something. A release process that starts the new instance, waits for a health check and only then moves traffic closes the gap; the mechanics are in [blue-green and rolling deployments](/blog/blue-green-vs-rolling-deployments).

### Doing the work inside the request

The last cause is on the application side, but the hosting shape usually encourages it. A handler that receives a push and then clones the repository, runs a build, posts a comment on the pull request or sends notifications will pass on a small repository and time out on a large one. [An old Stack Overflow question](https://stackoverflow.com/questions/46943006/send-response-but-keep-long-running-script-going-to-prevent-timeout) asks the same thing about a GitHub webhook that starts a long-running script: how to answer the sender straight away and let the script keep going.

## "Failed to connect to host" and other GitHub webhook errors, decoded

Each failed delivery in the log carries a short error string. Mapped to what was happening on your side, they read like this:

| What GitHub shows                                                   | What happened on your side                                                                                         | Where to look                           |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------- |
| Timed out                                                           | No response within 10 seconds: the app was asleep, cold, or busy doing the work in the request                     | Plan sleep settings, handler duration   |
| Failed to connect to network                                        | Connection refused: nothing was listening, typically between the old instance stopping and the new one starting    | Deploy logs at the delivery's timestamp |
| Failed to connect to host                                           | The hostname didn't resolve or the host was unreachable                                                            | DNS records, firewalls, IP allowlists   |
| Invalid HTTP response                                               | A 4xx or 5xx came back: a 502/503 from the platform proxy during a restart, or your own code rejecting the payload | Proxy logs, then app logs               |
| Peer certificate cannot be authenticated with given CA certificates | Self-signed or incomplete TLS certificate chain                                                                    | Certificate setup on the custom domain  |

Error strings as listed in GitHub's webhook troubleshooting documentation, as of September 2026.

Two further notes from [GitHub's troubleshooting page](https://docs.github.com/en/webhooks/testing-and-troubleshooting-webhooks/troubleshooting-webhooks): deliveries can arrive out of order or several minutes late, and GitHub may throttle them during traffic surges. Neither is a hosting fault, but both matter for how you process events once you have them.

## Acknowledge first, work later

The pattern GitHub itself recommends is to separate receiving from processing. The handler does three things only: checks the signature, stores the delivery somewhere durable, and replies. Everything else happens in a worker that reads from the queue at its own pace. The response then takes milliseconds regardless of how heavy the job is, and a slow job can no longer cause a timeout.

javascript

```
import express from "express";
import crypto from "node:crypto";
import { createClient } from "redis";

const redis = await createClient({ url: process.env.REDIS_URL }).connect();
const app = express();

app.post("/github", express.raw({ type: "application/json" }), async (req, res) => {
  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET)
    .update(req.body)
    .digest("hex");
  const received = req.get("X-Hub-Signature-256") ?? "";
  if (expected.length !== received.length ||
      !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
    return res.sendStatus(401);
  }

  const deliveryId = req.get("X-GitHub-Delivery");
  // A redelivery reuses the original ID, so this also drops duplicates.
  const isNew = await redis.set(`gh:seen:${deliveryId}`, "1", { NX: true, EX: 259200 });
  if (isNew) {
    try {
      await redis.lPush("gh:deliveries", JSON.stringify({
        id: deliveryId,
        event: req.get("X-GitHub-Event"),
        payload: req.body.toString("utf8"),
      }));
    } catch (error) {
      await redis.del(`gh:seen:${deliveryId}`);
      return res.sendStatus(500);
    }
  }

  res.sendStatus(202);
});

app.listen(process.env.PORT ?? 3000);
```

A few details in that sketch are deliberate. The body is read raw, because the signature is computed over the exact bytes GitHub sent and a parsed-then-reserialised body won't match; the full reasoning, including secret rotation, is in this guide to [verifying GitHub webhook signatures](https://webhooker.eu/blog/verify-github-webhook-signature). The `X-GitHub-Delivery` header is unique per event and stays the same when a delivery is resent, which makes it a natural deduplication key; the key expires after three days to match GitHub's redelivery window, and it is removed again if the queue write fails, so a later redelivery isn't mistaken for a duplicate. The sketch is an ES module, which is what allows the top-level `await`. Why dedupe at all, and what to do when two deliveries race, is covered in [idempotency keys for webhook consumers](https://webhooker.eu/blog/webhook-idempotency-keys).

### Why the queue has to live outside the process

The tempting shortcut is to reply 202 and then run the job on a background thread or an unawaited promise in the same process. It works until the next deploy. When the platform replaces the container, whatever that thread was doing is lost, along with any jobs queued in memory behind it. The delivery is green in GitHub, so nobody will ever resend it.

A Redis list, as above, or a jobs table in PostgreSQL both survive a restart of the receiver. The worker is a separate long-running process that pulls from that queue, so deploying the web endpoint doesn't interrupt jobs in flight. The choice between a queue, a scheduled job and an always-on worker is laid out in [cron jobs, background workers and queues](/blog/cron-jobs-workers-queues); a GitHub webhook receiver is the textbook case for the queue.

## A redelivery sweep for the misses you'll still have

An always-on receiver with a queue makes timeouts rare, not impossible. A certificate renewal that goes wrong, or an outage on GitHub's side, will still drop the odd delivery. Because GitHub won't retry, the safety net has to be yours: a scheduled job that lists recent deliveries for the hook, finds the failed ones and asks GitHub to resend them. GitHub's docs describe this approach and note that the script [should run on a schedule](https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries).

bash

```
# List recent deliveries for one repository webhook and resend the failed ones.
# OWNER, REPO and HOOK_ID are yours; gh must be authenticated with admin:repo_hook.
gh api "repos/OWNER/REPO/hooks/HOOK_ID/deliveries?per_page=100" \
  --jq 'group_by(.guid) | map(select(all(.[]; .status != "OK"))) | .[] | .[0].id' |
while read -r delivery_id; do
  gh api -X POST "repos/OWNER/REPO/hooks/HOOK_ID/deliveries/$delivery_id/attempts"
done
```

Run it every hour or so, well inside the three-day window. The filter groups attempts by the delivery's GUID and only picks deliveries where no attempt has succeeded, so a miss is resent once rather than on every run. A production version should also page through results and log what it resent. The deduplication in the handler is what makes the sweep safe to run often: a redelivered event with a known `X-GitHub-Delivery` is dropped instead of processed twice.

## How Runsite handles it

The receiver is a small [web service that stays up between deliveries](/services/web-services). On Runsite an active service doesn't cold-start, including on the €3/mo Nano plan, which sleeps only after 14 days with no requests at all; Starter (€5/mo) and above never sleep. A repository that pushes once a week still hits a running container. Releases are rolling behind a health check you configure, so the new version takes traffic only once it reports healthy and the old one keeps answering until then, which removes the deploy-window failures from the table above.

The worker runs as a second service from the same repository that doesn't serve HTTP, started at deploy and restarted with the app. The queue between them can be [managed Redis](/services/redis) from €5/mo or a table in [managed PostgreSQL](/services/postgresql) from €3/mo, and the redelivery sweep fits a [scheduled cron job](/services/cron-jobs) with failure alerts by email or webhook. All of it runs in Germany. Worth knowing for GitHub payloads in particular, since they carry commit authors' names and email addresses: the [data stays in the EU](/blog/gdpr-data-residency) under a signed data processing agreement.

## The short version

GitHub gives your endpoint 10 seconds to return a 2xx, then marks the delivery `Timed out`, `Failed to connect to host` or `Invalid HTTP response`, and never tries again on its own. Those seconds are spent before your code runs when the app is asleep, cold or mid-deploy. Host the receiver on something always-on, reply with 202 as soon as the signature checks out, push the payload to a queue outside the process, dedupe on `X-GitHub-Delivery`, and run a sweep that resends failures within three days.

Related service

## Web Services on Runsite

Deploy from a single git push and keep active apps warm — no cold starts, hosted entirely in the EU with a signed GDPR DPA on every plan.

[Explore Web Services](/services/web-services)

[Back to all articles](/blog)

FAQ

## Frequently Asked Questions

Common questions about this service.

### Why do webhooks fail?

For GitHub webhooks, most failures come from the receiving side rather than from GitHub. The endpoint takes longer than 10 seconds to answer because the app was asleep on a free tier, cold on a serverless platform, or doing heavy work inside the request; or nothing is listening because the app is being restarted during a deploy; or a proxy returns a 502 or 503 while no healthy instance is available. Less often the cause is DNS, a firewall blocking GitHub, or an incomplete TLS certificate chain. The error string in the Recent Deliveries tab (Timed out, Failed to connect to network, Failed to connect to host, Invalid HTTP response) tells you which of these it was.

### How to handle webhook retries?

With GitHub you have to handle them yourself, because GitHub does not automatically redeliver failed deliveries. You can resend any delivery from the past three days with the Redeliver button or through the REST API for repository, organisation and GitHub App webhooks. A reliable setup runs a scheduled job that lists recent deliveries, finds the failed ones and requests a redelivery for each. Since a redelivery keeps the original X-GitHub-Delivery ID, the receiver should record the IDs it has already processed and drop repeats, so the job can run often without creating duplicate work.

### How long does GitHub wait for a webhook response?

Ten seconds. GitHub's documentation says your server should respond with a 2XX status within 10 seconds of receiving a delivery; after that GitHub terminates the connection and records the delivery as failed with the error Timed out. The time covers everything before the response, including a sleeping container waking up and any proxy in front of the app, so the practical budget for your own code is smaller. The usual fix is to acknowledge with a 202 immediately and process the payload from a queue.

### Can I redeliver a failed GitHub webhook?

Yes, within three days. Open the webhook's settings, go to Recent Deliveries, select the failed delivery and choose Redeliver, or call the REST API endpoint that creates a new delivery attempt for that delivery ID. The API covers repository, organisation and GitHub App webhooks. After three days the delivery can no longer be resent, and GitHub does not redeliver anything on its own, so a missed event is permanent unless someone or something resends it in time.

Keep reading

## Related articles

[Deployment7 min readWhat Are Cold Starts — and Why Your Free-Tier App Keeps Falling AsleepCold starts make the first request to a sleeping app painfully slow. Here's why free hosting tiers spin down, what it costs you, and how to keep an app warm without paying for idle capacity.Jun 22, 2026Read](/blog/cold-starts)[Scheduling14 min readCron Jobs, Background Workers and Queues: Choosing the Right OneThree primitives that answer three different questions. What each one does when it fails, why the syntax half of this topic is disappearing from search, and where the job actually runs.Sep 4, 2026Read](/blog/cron-jobs-workers-queues)[Deployment11 min readBlue-Green vs Rolling Deployments: What Zero Downtime Costs YouBoth strategies promise your users never see the release. The difference that decides between them is how many versions of your code are talking to one database at the same time.Aug 17, 2026Read](/blog/blue-green-vs-rolling-deployments)

## Your app deserves to be online

€5 of credit on signup. Deploy in under a minute. No credit card needed.

[Start deploying](https://dashboard.runsite.app/login)[View documentation](https://docs.runsite.app)

---

Source: https://runsite.app/blog/github-webhook-timed-out
