DEV Community

Cover image for Your last error never arrives — flush before exit in Node (and Go)
Amorizz
Amorizz

Posted on

Your last error never arrives — flush before exit in Node (and Go)

TL;DR

  • captureException (and friends) queue the event. The HTTP POST is async.
  • process.exit / os.Exit / Lambda freeze can kill the process before that POST finishes. Dashboard stays empty.
  • Fix: await Sentry.flush() or await Sentry.close() in Node; sentry.Flush(2 * time.Second) in Go, before you die.

You ship a cron, a Lambda, a CLI, or a one-shot k8s Job. Something throws. You call captureException. The process exits. You refresh the error dashboard. Nothing. You re-check the DSN, blame "Sentry being flaky," then burn an afternoon on network paths that were fine the whole time.

The SDK did what you asked: it queued the event. The process died before the transport finished. This post is the portable flush recipe for Node (@sentry/node) and Go (getsentry/sentry-go). Same calls work against SaaS Sentry, GlitchTip, or any self-hosted ingest that speaks the Sentry protocol.

Why the last error vanishes

Capture is async. Exit ends the process. Anything still in the SDK queue never leaves the box.

Official SDKs do not block your handler on every captureException. They serialize the event, hand it to an internal transport, and return. That is correct for long-running servers: you do not want every error to stall a request.

Short-lived workers have a different lifetime. The job finishes in hundreds of milliseconds. You call process.exit(1) or the runtime freezes the Lambda. The event buffer still has work. No more event loop ticks. No more goroutine. The POST never happens.

Mental model: capture = "please send this soon." Flush/close = "block until the queue is empty (or the timeout hits), then I am allowed to exit."

Broken Node repro

Reproduce with captureException plus an immediate process.exit and no flush. Expect an empty issue stream even when the DSN is valid.

Pin the SDK you will actually run:

npm i @sentry/node@11
Enter fullscreen mode Exit fullscreen mode

broken-worker.mjs:

import * as Sentry from "@sentry/node";

Sentry.init({
  dsn: process.env.SENTRY_DSN, // any valid Sentry-protocol DSN
  tracesSampleRate: 0,
});

async function main() {
  try {
    throw new Error("flush-demo: cron blew up");
  } catch (err) {
    Sentry.captureException(err);
    // looks done… it is not
    process.exit(1);
  }
}

main();
Enter fullscreen mode Exit fullscreen mode

Run it:

SENTRY_DSN='https://PUBLIC@HOST/PROJECT' node broken-worker.mjs
Enter fullscreen mode Exit fullscreen mode

Expected when broken

  • Process exits in a few ms with code 1.
  • Your ingest host / SaaS project shows no new issue for flush-demo: cron blew up (or it appears much later / never, depending on race).
  • You will be tempted to rotate the DSN. Do not. Watch the network next.

Optional sanity check: temporarily set debug: true in Sentry.init. You often see the SDK still preparing or sending when the process already died.

Fixed Node (flush / close)

Await Sentry.flush(timeoutMs) if the process might keep running, or Sentry.close(timeoutMs) right before exit. Both return a Promise<boolean>: true if the queue drained in time.

fixed-worker.mjs:

import * as Sentry from "@sentry/node";

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  tracesSampleRate: 0,
});

async function main() {
  try {
    throw new Error("flush-demo: cron blew up");
  } catch (err) {
    Sentry.captureException(err);
    // drain the queue, then disable the client (safe before exit)
    const ok = await Sentry.close(2000);
    if (!ok) {
      console.warn("Sentry.close timed out; some events may be lost");
    }
    process.exit(1);
  }
}

main();
Enter fullscreen mode Exit fullscreen mode

If you only need to drain and the process continues (batch of jobs in one process), prefer flush:

const ok = await Sentry.flush(2000);
// client stays enabled
Enter fullscreen mode Exit fullscreen mode

SIGTERM / graceful shutdown (Compose, k8s, systemd):

async function shutdown(signal) {
  console.log(`got ${signal}, flushing Sentry`);
  await Sentry.close(2000);
  process.exit(0);
}

process.on("SIGTERM", () => {
  shutdown("SIGTERM");
});
process.on("SIGINT", () => {
  shutdown("SIGINT");
});
Enter fullscreen mode Exit fullscreen mode

Pinned versions used in this post

Package Pin
@sentry/node 11 (tested shape against 11.0.0 docs APIs)

Expected when fixed

  • close / flush takes up to your timeout (here 2s) and resolves true on a healthy network path.
  • The issue for flush-demo: cron blew up shows up on the project within a few seconds.
  • lastEventId() (optional) returns an id after capture; after a successful flush you can correlate it in the UI.

AWS Lambda note: the runtime can freeze after your handler returns. Prefer flushing inside the handler before you return, not only on a process-level signal you may never see.

Go: Flush before os.Exit

Call sentry.Flush(timeout) before the program terminates. defer is fine for normal returns; os.Exit skips defers, so flush manually before Exit.

go get github.com/getsentry/sentry-go@v0.49.0
Enter fullscreen mode Exit fullscreen mode
package main

import (
    "fmt"
    "os"
    "time"

    "github.com/getsentry/sentry-go"
)

func main() {
    err := sentry.Init(sentry.ClientOptions{
        Dsn: os.Getenv("SENTRY_DSN"),
    })
    if err != nil {
        fmt.Fprintf(os.Stderr, "sentry.Init: %v\n", err)
        os.Exit(1)
    }
    // covers normal return paths
    defer sentry.Flush(2 * time.Second)

    sentry.CaptureException(fmt.Errorf("flush-demo: job failed"))

    // os.Exit skips defers; flush first
    if !sentry.Flush(2 * time.Second) {
        fmt.Fprintln(os.Stderr, "sentry.Flush timed out; some events may be lost")
    }
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

Expected when fixed: Flush returns true and the exception appears on the project. When broken (capture + os.Exit with no Flush), you get the same empty-dashboard trap as Node.

Do not call Flush after every CaptureException in a hot loop. That turns async transport into sync spam. Flush at boundaries: end of job, before Exit, on shutdown.

Where this bites (and where it usually does not)

You need an explicit flush on short-lived processes. Long-running servers mostly get away without it, but SIGTERM handlers still benefit.

Runtime Usually needs flush? Why
Cron / systemd oneshot Yes Process exits as soon as the script ends
AWS Lambda / Cloud Functions Yes Freeze after return; buffer dies with the freeze
CLI / go run job Yes User hits Ctrl-C or you os.Exit
k8s Job / Compose restart: "no" one-shot Yes Same as cron
Long-running HTTP API Usually no Event loop / goroutines keep running; still flush on SIGTERM

If your "server" is actually a request-scoped worker that exits per message (some queue consumers), treat it like a cron.

Confirm steps + failure checklist

Confirm by forcing one known error, awaiting flush/close, and seeing that exact message in the project before you touch the DSN again.

Confirm

  1. Use a disposable message string (flush-demo: …) so you can find it fast.
  2. Run the fixed Node or Go snippet against a known-good DSN.
  3. Assert flush/close/Flush returned true (log it).
  4. Open the project: the issue exists with that message.
  5. Re-run the broken variant once so you trust the difference is flush, not luck.

Failure checklist

  1. process.exit / os.Exit before flush. Classic. Move exit after await close / Flush.
  2. Forgot that os.Exit skips defer. Go: flush explicitly before Exit; do not rely on deferred Flush alone.
  3. Timeout too short on a slow link. 2000 ms is a starting point; bump for cold starts / far regions, or accept loss and log false.
  4. Lambda returns before flush. Await flush inside the handler; do not only register process signals.
  5. Wrong DSN still possible. Flush succeeding with true but no UI event means auth/host/project mismatch. Fix DSN after flush is proven locally (debug transport logs help).
  6. Sample rates / beforeSend dropping the event. Flush only drains what the SDK kept. A filter that returns null will never arrive.
  7. Multiple clients / hubs. Flush the client that captured the event (or shut down each one you created).
  8. Fire-and-forget close() without await. In Node, a floating promise loses the race to process.exit again.

Same flush rule on any self-hosted error host

The SDK calls above are host-agnostic: they drain the client queue toward whatever DSN you configured. Same pattern for SaaS Sentry, GlitchTip, or another Sentry-protocol ingest. If you need a place to stand that host up, the quickstart covers the basics.

Discussion

Did you lose a prod-only exception because the worker exited before flush, and how long did you chase the wrong DSN?

Top comments (1)

Collapse
 
dev_supports profile image
DEV SUPPORTS •

Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support

‌‌ ‍