---
title: "Drain Pipeline"
description: "The pipeline wraps every drain: it batches events, retries on failure, fans out to several destinations, and ships browser logs."
canonical_url: "https://www.evlog.dev/extend/drain-pipeline"
---
# Drain Pipeline

> The pipeline wraps every drain: it batches events, retries on failure, fans out to several destinations, and ships browser logs.

In production, sending one HTTP request per emitted event doesn't scale. The drain pipeline buffers events and sends them in batches, retries on transient failures, drops the oldest events when the buffer overflows, and lets a single drain function fan out to several destinations in parallel. The same pipeline powers the [HTTP browser drain](#http-drain-browser-to-server) used for client-side logs.

<drain-pipeline-batching>



</drain-pipeline-batching>

<table>
<thead>
  <tr>
    <th>
      You want to…
    </th>
    
    <th>
      See
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Wrap any drain in batch + retry + buffer
    </td>
    
    <td>
      <a href="#quick-start">
        Quick start
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Send each event to several destinations in parallel
    </td>
    
    <td>
      <a href="#fanout">
        Fanout
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Ship browser logs to your server endpoint
    </td>
    
    <td>
      <a href="#http-drain-browser-to-server">
        HTTP drain (browser to server)
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Tune batch size, retry strategy, buffer size
    </td>
    
    <td>
      <a href="#configuration">
        Configuration
      </a>
    </td>
  </tr>
</tbody>
</table>

<prompt :actions="["copy","cursor","claude"]" description="Add the drain pipeline (batch + retry + fanout)" icon="i-lucide-workflow">

Wrap my evlog drain in the shared pipeline so it batches, retries, and survives transient failures.

- Import `createDrainPipeline` from `evlog/pipeline`
- Wrap the underlying drain: `const drain = createDrainPipeline<DrainContext>()(createAxiomDrain())`
- Configure `batch` (size + intervalMs), `retry` (maxAttempts + backoff), and `maxBufferSize` (oldest events drop on overflow). Sane defaults: `{ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 } }`
- For multiple destinations, write a single drain function that fans out internally with `Promise.allSettled([drainA(batch), drainB(batch)])` and pass that to `pipeline(...)`. One shared buffer, one shared retry budget
- On shutdown, call `drain.flush()` to push buffered events (Nitro `close` hook, Standalone before `process.exit`). On serverless, passing the pipeline as the `drain` option is enough: the middleware registers `drain.settled()` with the runtime's `waitUntil`
- Always use the pipeline in production. Direct drains make one HTTP call per event and fall over fast

Docs: [https://www.evlog.dev/extend/drain-pipeline](https://www.evlog.dev/extend/drain-pipeline)
Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview)

</prompt>

## Quick start

The pipeline wraps any drain. The wiring depends on your framework, so pick the tab that matches yours; every other example below uses the same shape.

<framework-tabs>

```typescript [Nuxt]server/plugins/evlog-drain.ts
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

export default defineNitroPlugin((nitroApp) => {
  const pipeline = createDrainPipeline<DrainContext>()
  const drain = pipeline(createAxiomDrain())

  nitroApp.hooks.hook('evlog:drain', drain)
  nitroApp.hooks.hook('close', () => drain.flush())
})
```

```typescript [Nitro]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

export default definePlugin((nitroApp) => {
  const pipeline = createDrainPipeline<DrainContext>()
  const drain = pipeline(createAxiomDrain())

  nitroApp.hooks.hook('evlog:drain', drain)
  nitroApp.hooks.hook('close', () => drain.flush())
})
```

```typescript [Next.js]lib/evlog.ts
import type { DrainContext } from 'evlog'
import { createEvlog } from 'evlog/next'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(createAxiomDrain())

export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',
  drain,
})

export const flushEvlog = () => drain.flush()
```

```typescript [SvelteKit]src/hooks.server.ts
import { createEvlogHooks } from 'evlog/sveltekit'
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(createAxiomDrain())

export const { handle, handleError } = createEvlogHooks({ drain })

process.on('SIGTERM', () => drain.flush())
```

```typescript [TanStack Start]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

export default definePlugin((nitroApp) => {
  const pipeline = createDrainPipeline<DrainContext>()
  const drain = pipeline(createAxiomDrain())

  nitroApp.hooks.hook('evlog:drain', drain)
  nitroApp.hooks.hook('close', () => drain.flush())
})
```

```typescript [React Router]app/root.tsx
import { evlog } from 'evlog/react-router'
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(createAxiomDrain())

export const middleware: Route.MiddlewareFunction[] = [
  evlog({ drain }),
]

process.on('SIGTERM', () => drain.flush())
```

```typescript [Hono]src/index.ts
import { evlog } from 'evlog/hono'
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(createAxiomDrain())

app.use(evlog({ drain }))

process.on('SIGTERM', () => drain.flush())
```

```typescript [Express]src/index.ts
import { evlog } from 'evlog/express'
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(createAxiomDrain())

app.use(evlog({ drain }))

process.on('SIGTERM', () => drain.flush())
```

```typescript [Fastify]src/index.ts
import { evlog } from 'evlog/fastify'
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(createAxiomDrain())

await app.register(evlog, { drain })

process.on('SIGTERM', () => drain.flush())
```

```typescript [Elysia]src/index.ts
import { evlog } from 'evlog/elysia'
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(createAxiomDrain())

app.use(evlog({ drain }))

process.on('SIGTERM', () => drain.flush())
```

```typescript [NestJS]src/app.module.ts
import { EvlogModule } from 'evlog/nestjs'
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(createAxiomDrain())

EvlogModule.forRoot({ drain })

process.on('SIGTERM', () => drain.flush())
```

```typescript [oRPC]server/orpc.ts
import { withEvlog } from 'evlog/orpc'
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(createAxiomDrain())

const handler = withEvlog(new RPCHandler(router), { drain })

process.on('SIGTERM', () => drain.flush())
```

```typescript [Cloudflare Workers]src/worker.ts
import { withEvlog } from 'evlog/workers'
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(createAxiomDrain())

export default withEvlog(handler, { drain })
```

```typescript [Standalone]index.ts
// Plain TypeScript / Bun / Node script
import type { DrainContext } from 'evlog'
import { initLogger } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(createAxiomDrain())

initLogger({ drain })

await drain.flush() // before exit
```

</framework-tabs>

<callout color="warning" icon="i-lucide-alert-triangle">

Always flush the pipeline before the process exits (`drain.flush()`). On Nitro use the `close` hook; on standalone scripts call it before `process.exit`. On serverless runtimes the middleware extends the instance's lifetime for you, see [Serverless](#serverless).

</callout>

## What the pipeline does to an event

Events are buffered as they arrive on `evlog:drain`. A batch flushes when either `batch.size` is reached or `batch.intervalMs` expires (whichever comes first). On failure, the same batch is retried with the configured backoff; once `retry.maxAttempts` is exhausted, `onDropped` is called with the lost events (or the drop is logged when you didn't configure one). The buffer is bounded by `maxBufferSize`. Once full, the oldest events are dropped to keep memory flat.

### The pipeline owns retries

Adapter drains (`createAxiomDrain()`, `createDatadogDrain()`, …) swallow delivery failures when called directly, so a failing backend never breaks a request. Inside the pipeline that would make `retry` and `onDropped` dead code, so the pipeline unwraps the adapter to its throwing `raw` variant: failures reach the retry loop, and raw delivery defaults to a single HTTP attempt so the batch isn't retried twice. Set `retries` on the adapter config only if you explicitly want extra HTTP attempts inside each pipeline attempt.

## Configuration

```typescript [pipeline-config.ts]
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'

const pipeline = createDrainPipeline<DrainContext>({
  batch: {
    size: 50,          // Flush every 50 events
    intervalMs: 5000,  // Or every 5 seconds, whichever comes first
  },
  retry: {
    maxAttempts: 3,
    backoff: 'exponential',
    initialDelayMs: 1000,
    maxDelayMs: 30000,
  },
  maxBufferSize: 1000,
  onDropped: (events, error) => {
    console.error(`[evlog] Dropped ${events.length} events:`, error?.message)
  },
})

export const drain = pipeline(createAxiomDrain())
```

### Every option, and its default

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        batch.size
      </code>
    </td>
    
    <td>
      <code>
        50
      </code>
    </td>
    
    <td>
      Maximum events per batch
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        batch.intervalMs
      </code>
    </td>
    
    <td>
      <code>
        5000
      </code>
    </td>
    
    <td>
      Max time (ms) before flushing a partial batch
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        retry.maxAttempts
      </code>
    </td>
    
    <td>
      <code>
        3
      </code>
    </td>
    
    <td>
      Total attempts including the initial one
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        retry.backoff
      </code>
    </td>
    
    <td>
      <code>
        'exponential'
      </code>
    </td>
    
    <td>
      <code>
        'exponential'
      </code>
      
       | <code>
        'linear'
      </code>
      
       | <code>
        'fixed'
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        retry.initialDelayMs
      </code>
    </td>
    
    <td>
      <code>
        1000
      </code>
    </td>
    
    <td>
      Base delay for the first retry
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        retry.maxDelayMs
      </code>
    </td>
    
    <td>
      <code>
        30000
      </code>
    </td>
    
    <td>
      Upper bound for any retry delay
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        maxBufferSize
      </code>
    </td>
    
    <td>
      <code>
        1000
      </code>
    </td>
    
    <td>
      Max buffered events before dropping oldest
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        onDropped
      </code>
    </td>
    
    <td>
      -
    </td>
    
    <td>
      Callback when events are dropped (overflow or retry exhaustion)
    </td>
  </tr>
</tbody>
</table>

### Choose how retries space out

<table>
<thead>
  <tr>
    <th>
      Strategy
    </th>
    
    <th>
      Delay pattern
    </th>
    
    <th>
      Use case
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        exponential
      </code>
    </td>
    
    <td>
      1s, 2s, 4s, 8s…
    </td>
    
    <td>
      Default. Best for transient failures that may need time to recover
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        linear
      </code>
    </td>
    
    <td>
      1s, 2s, 3s, 4s…
    </td>
    
    <td>
      Predictable delay growth
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        fixed
      </code>
    </td>
    
    <td>
      1s, 1s, 1s, 1s…
    </td>
    
    <td>
      Same delay every time. Useful for rate-limited APIs
    </td>
  </tr>
</tbody>
</table>

### What the wrapper hands back

The function returned by `pipeline(drain)` is hook-compatible and exposes:

<table>
<thead>
  <tr>
    <th>
      Property
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        drain(ctx)
      </code>
    </td>
    
    <td>
      <code>
        (ctx: T) => void
      </code>
    </td>
    
    <td>
      Push a single event into the buffer
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        drain.flush()
      </code>
    </td>
    
    <td>
      <code>
        () => Promise<void>
      </code>
    </td>
    
    <td>
      Force-flush all buffered events
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        drain.settled()
      </code>
    </td>
    
    <td>
      <code>
        () => Promise<void>
      </code>
    </td>
    
    <td>
      Resolves once everything buffered so far is delivered or dropped, without forcing a flush
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        drain.pending
      </code>
    </td>
    
    <td>
      <code>
        number
      </code>
    </td>
    
    <td>
      Number of events currently buffered
    </td>
  </tr>
</tbody>
</table>

## Serverless

`drain(ctx)` returns after buffering, so on a serverless runtime the interesting question is what keeps the instance alive until the batch actually ships. When you pass the pipeline as the `drain` option of a framework integration, the middleware registers `drain.settled()` with the runtime's `waitUntil` (Next's `after()`, Workers' `ctx.waitUntil`): the instance stays alive until the buffered batch is delivered or dropped. `batch.intervalMs` bounds when the first delivery attempt starts; retry backoff can extend the lifetime past it until delivery succeeds or `retry.maxAttempts` is exhausted. Lower `intervalMs` on serverless if you'd rather trade batching for shorter post-response lifetimes.

Nitro is the exception: drains registered on the `evlog:drain` hook are opaque to the plugin, so it can't extend the lifetime for you. On Node the `close` hook flush from the quick start covers shutdown; on Cloudflare register `drain.settled()` with the execution context's `waitUntil` yourself where you handle the request.

## Fanout

<drain-fan-out>



</drain-fan-out>

In production, the same wide event often needs to reach more than one destination: a long-term store (Axiom), a metrics tool (Datadog), an error tracker (Sentry), and a local fs drain for incident replay. The pipeline batches once, then your drain fans out the batch to every destination in parallel via `Promise.allSettled` so a single slow / failing destination cannot block the others.

<prompt :actions="["copy","cursor","claude"]" description="Fan out evlog events to multiple destinations" icon="i-lucide-share-2">

Send each wide event to several destinations in parallel through a single drain pipeline.

- Wrap a single `createDrainPipeline` from `evlog/pipeline` around a fan-out function that calls every destination drain inside `Promise.allSettled([drainA(batch), drainB(batch), …])`. Use `allSettled` so one failing drain doesn't reject the whole batch
- Pick destinations by purpose: long-term store (Axiom / Better Stack / Datadog), error tracker (Sentry, typically `{ minLevel: 'error' }` so it doesn't get all events), local replay (`createFsDrain`)
- Tune `batch.size`, `batch.intervalMs`, `retry.maxAttempts`, and `maxBufferSize` once at the pipeline level, which applies to all destinations
- For destinations that need different filtering, prefer per-drain `minLevel` / `filter` options over wrapping
- Don't forget `drain.flush()` on shutdown. Events buffered for fanout are lost on abrupt exit

Docs: [https://www.evlog.dev/extend/drain-pipeline](https://www.evlog.dev/extend/drain-pipeline)

</prompt>

### Wire the fan-out

```ts
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'
import { createDatadogDrain } from 'evlog/datadog'
import { createSentryDrain } from 'evlog/sentry'
import { createFsDrain } from 'evlog/fs'
import type { DrainContext } from 'evlog'

const pipeline = createDrainPipeline<DrainContext>({
  batch: { size: 50, intervalMs: 5000 },
  retry: { maxAttempts: 3 },
  maxBufferSize: 1000,
})

const axiom = createAxiomDrain()
const datadog = createDatadogDrain()
const sentry = createSentryDrain({ minLevel: 'error' })
const fs = createFsDrain({ dir: '.evlog/logs', maxFiles: 14 })

export const drain = pipeline(async (batch) => {
  await Promise.allSettled([
    axiom(batch),
    datadog(batch),
    sentry(batch),
    fs(batch),
  ])
})
```

### What fan-out guarantees

- **Parallel dispatch**: every destination receives the batch concurrently via `Promise.allSettled`
- **Tolerant fanout**: if Datadog's API throws, Axiom / Sentry / fs still complete; the pipeline only retries the whole batch when the wrapping function rejects
- **Shared backpressure**: the buffer is sized once for the whole pipeline; if the wrapping drain falls behind, the oldest events are dropped consistently for every destination

### Send different events to each drain

Wrap a destination drain so it only sees events you care about:

```ts
import type { DrainContext } from 'evlog'

const sentry = createSentryDrain({ dsn: process.env.SENTRY_DSN! })

async function sentryErrorsOnly(batch: DrainContext[]): Promise<void> {
  const errors = batch.filter(c => c.event?.level === 'error')
  if (errors.length > 0) await sentry(errors)
}

export const drain = pipeline(async (batch) => {
  await Promise.allSettled([
    axiom(batch),
    sentryErrorsOnly(batch),
  ])
})
```

Most built-in drains expose `minLevel` directly, so you only need this pattern for non-level filters (path, custom field, etc.).

## Write the drain yourself

You don't need an adapter. Pass any async function that accepts a batch:

```typescript [pipeline-custom.ts]
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'

const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 100 } })

export const drain = pipeline(async (batch) => {
  await fetch('https://your-service.com/logs', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(batch.map(ctx => ctx.event)),
  })
})
```

For anything more involved (config resolution, retries, identity headers), use [`defineHttpDrain`](https://www.evlog.dev/extend/custom-drains) instead and let the toolkit handle the boilerplate.

## HTTP drain (browser to server)

The HTTP drain ships client-side logs to your server, whatever framework serves it. It composes on top of the same pipeline primitives, with browser-specific defaults (`fetch keepalive` + `sendBeacon` on `visibilitychange`).

<callout color="neutral" icon="i-lucide-info">

The `evlog/browser` import path is **deprecated** and re-exports the same API as `evlog/http`. It will be removed in the next **major** release. Prefer `evlog/http` for new code.

</callout>

<prompt :actions="["copy","cursor","claude"]" description="Set up the HTTP transport for client logs" icon="i-lucide-globe">

Set up the HTTP transport so my browser logs are sent to my server.

- Install evlog: pnpm add evlog
- Import `createHttpLogDrain` from `evlog/http` (NOT `evlog/browser`, which is deprecated)
- Create a drain with `{ endpoint: 'https://logs.example.com/v1/ingest' }` and an optional pipeline (`{ batch: { size, intervalMs } }`)
- Pass the drain to `initLogger({ drain })` on the client side
- The drain batches events and uses `fetch keepalive` + `sendBeacon` on `visibilitychange`
- On the server, accept POST requests with a `DrainContext[]` body and forward them to my drain pipeline

Docs: [https://www.evlog.dev/extend/drain-pipeline](https://www.evlog.dev/extend/drain-pipeline)

</prompt>

### Quick start

```typescript [app.ts]
import { initLogger, log } from 'evlog'
import { createHttpLogDrain } from 'evlog/http'

const drain = createHttpLogDrain({
  drain: { endpoint: 'https://logs.example.com/v1/ingest' },
})
initLogger({ drain })

log.info({ action: 'page_view', path: location.pathname })
```

### How it works (browser specifics)

1. `log.info()` / `log.warn()` / `log.error()` push events into a memory buffer
2. Events are batched by size (default 25) or time interval (default 2 s)
3. Batches are sent via `fetch` with `keepalive: true` so requests survive page navigation
4. When the page becomes hidden (tab switch, navigation), buffered events are flushed via `navigator.sendBeacon` as a fallback
5. Your server endpoint receives a `DrainContext[]` JSON array and processes it however you like

### Pick the tier you need

#### `createHttpLogDrain(options)`

High-level, pre-composed: creates a pipeline with batching, retry, and auto-flush on `visibilitychange`. Returns a `PipelineDrainFn<DrainContext>` directly usable with `initLogger({ drain })`.

```typescript [app.ts]
import { initLogger, log } from 'evlog'
import { createHttpLogDrain } from 'evlog/http'

const drain = createHttpLogDrain({
  drain: { endpoint: 'https://logs.example.com/v1/ingest' },
  pipeline: { batch: { size: 50, intervalMs: 5000 } },
})

initLogger({ drain })
log.info({ action: 'click', target: 'buy-button' })
```

#### `createHttpDrain(config)`

Low-level transport function. Use this when you want full control over the pipeline configuration:

```typescript [app.ts]
import { createHttpDrain } from 'evlog/http'
import { createDrainPipeline } from 'evlog/pipeline'
import type { DrainContext } from 'evlog'

const transport = createHttpDrain({
  endpoint: 'https://logs.example.com/v1/ingest',
})
const pipeline = createDrainPipeline<DrainContext>({
  batch: { size: 100, intervalMs: 10000 },
  retry: { maxAttempts: 5 },
})

const drain = pipeline(transport)
```

### Every browser option

#### `HttpDrainConfig`

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        endpoint
      </code>
    </td>
    
    <td>
      -
    </td>
    
    <td>
      <strong>
        (required)
      </strong>
      
       Full URL of the server ingest endpoint
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        headers
      </code>
    </td>
    
    <td>
      -
    </td>
    
    <td>
      Custom headers sent with each <code>
        fetch
      </code>
      
       request (e.g. <code>
        Authorization
      </code>
      
      , <code>
        X-API-Key
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        timeout
      </code>
    </td>
    
    <td>
      <code>
        5000
      </code>
    </td>
    
    <td>
      Request timeout in milliseconds
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        useBeacon
      </code>
    </td>
    
    <td>
      <code>
        true
      </code>
    </td>
    
    <td>
      Use <code>
        sendBeacon
      </code>
      
       when the page is hidden
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        credentials
      </code>
    </td>
    
    <td>
      <code>
        'same-origin'
      </code>
    </td>
    
    <td>
      Fetch credentials mode (<code>
        'omit'
      </code>
      
      , <code>
        'same-origin'
      </code>
      
      , <code>
        'include'
      </code>
      
      ). Set to <code>
        'include'
      </code>
      
       for cross-origin endpoints
    </td>
  </tr>
</tbody>
</table>

#### `HttpLogDrainOptions`

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        drain
      </code>
    </td>
    
    <td>
      -
    </td>
    
    <td>
      <strong>
        (required)
      </strong>
      
       <code>
        HttpDrainConfig
      </code>
      
       object
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        pipeline
      </code>
    </td>
    
    <td>
      <code>
        { batch: { size: 25, intervalMs: 2000 }, retry: { maxAttempts: 2 } }
      </code>
    </td>
    
    <td>
      Pipeline configuration overrides
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        autoFlush
      </code>
    </td>
    
    <td>
      <code>
        true
      </code>
    </td>
    
    <td>
      Auto-register <code>
        visibilitychange
      </code>
      
       flush listener
    </td>
  </tr>
</tbody>
</table>

### Survive a closing tab

<callout color="info" icon="i-lucide-radio">

When `useBeacon` is enabled (the default) and the page becomes hidden, the drain automatically switches from `fetch` to `navigator.sendBeacon`. This ensures logs are delivered even when the user closes the tab or navigates away.

</callout>

`sendBeacon` has a browser-imposed payload limit (~64 KB). If the payload exceeds this, the drain throws an error. Keep batch sizes reasonable (the default of 25 is well within limits).

### Authenticate the browser request

Pass custom headers to protect your ingest endpoint:

```typescript [app.ts]
const drain = createHttpLogDrain({
  drain: {
    endpoint: 'https://logs.example.com/v1/ingest',
    headers: {
      'Authorization': 'Bearer ' + token,
    },
  },
})
```

<callout color="warning" icon="i-lucide-shield-alert">

`headers` are applied to `fetch` requests only. The `sendBeacon` API does not support custom headers, so when the page is hidden and `sendBeacon` is used, headers are not sent. If your endpoint requires authentication, validate via a session cookie (set `credentials: 'include'` for cross-origin endpoints) or disable `sendBeacon` with `useBeacon: false`.

</callout>

### Server endpoint

Your server needs a POST endpoint that accepts a `DrainContext[]` JSON body. Examples for common frameworks:

<framework-tabs>

```typescript [Express]src/index.ts
app.post('/v1/ingest', express.json(), (req, res) => {
  for (const entry of req.body) {
    console.log('[BROWSER]', JSON.stringify(entry))
  }
  res.sendStatus(204)
})
```

```typescript [Hono]src/index.ts
app.post('/v1/ingest', async (c) => {
  const body = await c.req.json()
  for (const entry of body) {
    console.log('[BROWSER]', JSON.stringify(entry))
  }
  return c.body(null, 204)
})
```

</framework-tabs>

See the full [browser example](https://github.com/evloghq/evlog/tree/main/examples/browser) for a working Hono server + browser page.

## Mistakes that cost you events

- **Don't forget drain.flush() on shutdown**: buffered events are lost otherwise
- **Tune batch.size to match your provider's recommended payload**: too small wastes overhead, too big risks rejection
- **Don't run one pipeline per drain unless you need per-destination retries**: sharing one pipeline keeps batching + buffering coherent
- **Don't fan out to a serverless-incompatible target without checking**: the [stream server](https://www.evlog.dev/extend/stream) reaches every connected client through the in-process stream; it's not a drain

## Next steps

- [Custom Drains](https://www.evlog.dev/extend/custom-drains): build a drain for any backend with `defineHttpDrain` / `defineDrain`
- [Adapters Overview](https://www.evlog.dev/integrate/adapters/overview): built-in adapters that work with the pipeline out of the box
- [Best Practices](https://www.evlog.dev/reference/best-practices): security and production tips
- [Client logging](https://www.evlog.dev/use-cases/client-logging): end-to-end browser → server flow

---

- [Custom Drains](https://www.evlog.dev/extend/custom-drains)
- [Adapters Overview](https://www.evlog.dev/integrate/adapters/overview)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
