Back to list
SecurityRonin

server-sent-events

by SecurityRonin

Battle-tested skills for Claude Code. Deployment patterns, browser automation, and hard-won knowledge from real projects.

0🍴 0📅 Jan 17, 2026

SKILL.md


name: server-sent-events description: Server-Sent Events (SSE) for real-time progress updates in Next.js/Vercel. TransformStream patterns, client consumption, progress tracking. triggers:

  • sse
  • server-sent events
  • streaming
  • progress updates
  • real-time
  • event stream

Server-Sent Events (SSE)

Real-time server-to-client streaming for progress updates, notifications, and live data feeds.

When to Use SSE vs Alternatives

TechnologyUse WhenLimitations
SSEServer → Client only, progress updates, live feedsUnidirectional, limited connections
WebSocketsBidirectional chat, gaming, collaborationMore complex, stateful
PollingSimple updates, wide compatibilityInefficient, latency
HTTP StreamingLarge file downloadsNo structured events

Choose SSE for: Progress bars, status updates, notifications, log streaming, live dashboards.


Next.js App Router Implementation

Server-Side (Route Handler)

// app/api/progress/route.ts
import { NextRequest } from 'next/server'

interface SSEMessage {
  progress: number
  message: string
  status: 'progress' | 'complete' | 'error'
}

export async function POST(req: NextRequest) {
  const body = await req.json()

  // SSE Response Headers
  const headers = {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  }

  // Create transform stream for SSE
  const stream = new TransformStream()
  const writer = stream.writable.getWriter()
  const encoder = new TextEncoder()

  // Helper to send SSE messages
  async function sendSSE(data: SSEMessage) {
    await writer.write(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
  }

  // Execute work with progress updates (async, don't await)
  ;(async () => {
    try {
      await sendSSE({ progress: 0, message: 'Starting...', status: 'progress' })

      // Simulate work with progress updates
      for (let i = 1; i <= 10; i++) {
        await doSomeWork(i)
        await sendSSE({
          progress: i * 10,
          message: `Processing step ${i} of 10...`,
          status: 'progress'
        })
      }

      await sendSSE({ progress: 100, message: 'Complete!', status: 'complete' })
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error'
      await sendSSE({ progress: 0, message, status: 'error' })
    } finally {
      await writer.close()
    }
  })()

  return new Response(stream.readable, { headers })
}

Client-Side Consumption

// React component
'use client'

import { useState } from 'react'

interface ProgressState {
  progress: number
  message: string
  status: 'idle' | 'progress' | 'complete' | 'error'
}

export function ProgressTracker() {
  const [state, setState] = useState<ProgressState>({
    progress: 0,
    message: '',
    status: 'idle'
  })

  async function startProcess() {
    setState({ progress: 0, message: 'Connecting...', status: 'progress' })

    const response = await fetch('/api/progress', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ /* request data */ })
    })

    if (!response.body) {
      setState({ progress: 0, message: 'No response body', status: 'error' })
      return
    }

    const reader = response.body.getReader()
    const decoder = new TextDecoder()
    let buffer = ''

    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      buffer += decoder.decode(value, { stream: true })

      // Parse SSE messages from buffer
      const lines = buffer.split('\n\n')
      buffer = lines.pop() || '' // Keep incomplete message in buffer

      for (const line of lines) {
        if (line.startsWith('data: ')) {
          const data = JSON.parse(line.slice(6))
          setState({
            progress: data.progress,
            message: data.message,
            status: data.status
          })
        }
      }
    }
  }

  return (
    <div>
      <button onClick={startProcess} disabled={state.status === 'progress'}>
        Start Process
      </button>
      <progress value={state.progress} max={100} />
      <p>{state.message}</p>
    </div>
  )
}

SSE Message Format

Standard Format

data: {"key": "value"}\n\n

Rules:

  • Each message ends with \n\n (double newline)
  • Data line starts with data:
  • Multiple data lines allowed per message
  • Optional event:, id:, retry: fields

Named Events

// Server
async function sendNamedEvent(event: string, data: object) {
  await writer.write(encoder.encode(
    `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
  ))
}

// Send different event types
await sendNamedEvent('progress', { percent: 50 })
await sendNamedEvent('log', { message: 'Processing...' })
await sendNamedEvent('complete', { result: 'success' })
// Client with EventSource (for GET endpoints)
const source = new EventSource('/api/events')

source.addEventListener('progress', (e) => {
  const data = JSON.parse(e.data)
  console.log('Progress:', data.percent)
})

source.addEventListener('complete', (e) => {
  const data = JSON.parse(e.data)
  console.log('Done:', data.result)
  source.close()
})

Vercel Considerations

Critical: Background Task Termination

// CORRECT: Stream keeps function alive until closed
;(async () => {
  try {
    // Do all work here
    for (const item of items) {
      await processItem(item)
      await sendSSE({ progress: calculateProgress(item) })
    }
    await sendSSE({ status: 'complete' })
  } finally {
    await writer.close() // MUST close to end function
  }
})()

return new Response(stream.readable, { headers })

Timeout Limits

PlanMax Duration
Hobby10 seconds
Pro60 seconds
Enterprise900 seconds

For long operations, consider:

  • Breaking into smaller chunks
  • Using background jobs (Vercel Cron, external queue)
  • Providing estimated time warnings

Progress Patterns

Percentage-Based

interface PercentageProgress {
  progress: number  // 0-100
  message: string
  status: 'progress' | 'complete' | 'error'
}

// Calculate progress for known item count
const total = items.length
for (let i = 0; i < items.length; i++) {
  await processItem(items[i])
  await sendSSE({
    progress: Math.floor(((i + 1) / total) * 100),
    message: `Processing ${i + 1} of ${total}...`,
    status: 'progress'
  })
}

Phase-Based

interface PhaseProgress {
  phase: string
  phaseProgress: number
  overallProgress: number
  message: string
}

const phases = [
  { name: 'validate', weight: 10 },
  { name: 'process', weight: 70 },
  { name: 'finalize', weight: 20 },
]

// Track progress across phases
let overallProgress = 0
for (const phase of phases) {
  await sendSSE({
    phase: phase.name,
    phaseProgress: 0,
    overallProgress,
    message: `Starting ${phase.name}...`
  })

  // Do phase work...

  overallProgress += phase.weight
}

Indeterminate with Logs

interface LogProgress {
  type: 'log' | 'warning' | 'error' | 'complete'
  message: string
  timestamp: string
}

// For operations where progress can't be calculated
await sendSSE({
  type: 'log',
  message: 'Connecting to external service...',
  timestamp: new Date().toISOString()
})

Error Handling

Server-Side

;(async () => {
  try {
    await riskyOperation()
    await sendSSE({ status: 'complete', message: 'Done!' })
  } catch (error) {
    // Always send error to client before closing
    await sendSSE({
      status: 'error',
      message: error instanceof Error ? error.message : 'Unknown error',
      progress: 0
    })
  } finally {
    // ALWAYS close the stream
    await writer.close()
  }
})()

Client-Side

async function consumeSSE(url: string, body: object) {
  try {
    const response = await fetch(url, {
      method: 'POST',
      body: JSON.stringify(body)
    })

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }

    // Process stream...
  } catch (error) {
    // Handle network errors, aborts, etc.
    if (error.name === 'AbortError') {
      console.log('Request cancelled')
    } else {
      console.error('SSE failed:', error)
    }
  }
}

Cancellation

// Client with AbortController
const controller = new AbortController()

const response = await fetch('/api/progress', {
  method: 'POST',
  body: JSON.stringify(data),
  signal: controller.signal
})

// Cancel button handler
function handleCancel() {
  controller.abort()
}

Real-World Example: Batch Processing

// app/api/batch/route.ts
export async function POST(req: NextRequest) {
  const { items } = await req.json()

  const stream = new TransformStream()
  const writer = stream.writable.getWriter()
  const encoder = new TextEncoder()

  const sendSSE = async (data: object) => {
    await writer.write(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
  }

  ;(async () => {
    const results = { success: 0, failed: 0, errors: [] as string[] }

    try {
      await sendSSE({
        status: 'progress',
        progress: 0,
        message: `Processing ${items.length} items...`
      })

      for (let i = 0; i < items.length; i++) {
        try {
          await processItem(items[i])
          results.success++
        } catch (err) {
          results.failed++
          results.errors.push(`Item ${i}: ${err.message}`)
        }

        await sendSSE({
          status: 'progress',
          progress: Math.floor(((i + 1) / items.length) * 100),
          message: `Processed ${i + 1} of ${items.length}`,
          results: { ...results }
        })
      }

      await sendSSE({
        status: 'complete',
        progress: 100,
        message: `Complete: ${results.success} succeeded, ${results.failed} failed`,
        results
      })
    } catch (error) {
      await sendSSE({
        status: 'error',
        message: error instanceof Error ? error.message : 'Batch failed',
        results
      })
    } finally {
      await writer.close()
    }
  })()

  return new Response(stream.readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    }
  })
}

Quick Reference

// Minimal SSE endpoint
export async function POST(req: NextRequest) {
  const stream = new TransformStream()
  const writer = stream.writable.getWriter()
  const encoder = new TextEncoder()

  ;(async () => {
    await writer.write(encoder.encode(`data: {"message":"Hello"}\n\n`))
    await writer.close()
  })()

  return new Response(stream.readable, {
    headers: { 'Content-Type': 'text/event-stream' }
  })
}
// Minimal client consumption
const res = await fetch('/api/sse', { method: 'POST' })
const reader = res.body!.getReader()
const decoder = new TextDecoder()

while (true) {
  const { done, value } = await reader.read()
  if (done) break
  console.log(decoder.decode(value))
}

Resources

Score

Total Score

70/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

+10
説明文

100文字以上の説明がある

+10
人気

GitHub Stars 100以上

0/15
最近の活動

1ヶ月以内に更新

+10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

0/5
タグ

1つ以上のタグが設定されている

+5

Reviews

💬

Reviews coming soon