Server-Sent Events (SSE)
Real-time server-to-client streaming for progress updates, notifications, and live data feeds.
When to Use SSE vs Alternatives
| Technology | Use When | Limitations |
|---|
| SSE | Server → Client only, progress updates, live feeds | Unidirectional, limited connections |
| WebSockets | Bidirectional chat, gaming, collaboration | More complex, stateful |
| Polling | Simple updates, wide compatibility | Inefficient, latency |
| HTTP Streaming | Large file downloads | No structured events |
Choose SSE for: Progress bars, status updates, notifications, log streaming, live dashboards.
Next.js App Router Implementation
Server-Side (Route Handler)
typescript
1// app/api/progress/route.ts
2import { NextRequest } from 'next/server'
3
4interface SSEMessage {
5 progress: number
6 message: string
7 status: 'progress' | 'complete' | 'error'
8}
9
10export async function POST(req: NextRequest) {
11 const body = await req.json()
12
13 // SSE Response Headers
14 const headers = {
15 'Content-Type': 'text/event-stream',
16 'Cache-Control': 'no-cache',
17 'Connection': 'keep-alive',
18 }
19
20 // Create transform stream for SSE
21 const stream = new TransformStream()
22 const writer = stream.writable.getWriter()
23 const encoder = new TextEncoder()
24
25 // Helper to send SSE messages
26 async function sendSSE(data: SSEMessage) {
27 await writer.write(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
28 }
29
30 // Execute work with progress updates (async, don't await)
31 ;(async () => {
32 try {
33 await sendSSE({ progress: 0, message: 'Starting...', status: 'progress' })
34
35 // Simulate work with progress updates
36 for (let i = 1; i <= 10; i++) {
37 await doSomeWork(i)
38 await sendSSE({
39 progress: i * 10,
40 message: `Processing step ${i} of 10...`,
41 status: 'progress'
42 })
43 }
44
45 await sendSSE({ progress: 100, message: 'Complete!', status: 'complete' })
46 } catch (error) {
47 const message = error instanceof Error ? error.message : 'Unknown error'
48 await sendSSE({ progress: 0, message, status: 'error' })
49 } finally {
50 await writer.close()
51 }
52 })()
53
54 return new Response(stream.readable, { headers })
55}
Client-Side Consumption
typescript
1// React component
2'use client'
3
4import { useState } from 'react'
5
6interface ProgressState {
7 progress: number
8 message: string
9 status: 'idle' | 'progress' | 'complete' | 'error'
10}
11
12export function ProgressTracker() {
13 const [state, setState] = useState<ProgressState>({
14 progress: 0,
15 message: '',
16 status: 'idle'
17 })
18
19 async function startProcess() {
20 setState({ progress: 0, message: 'Connecting...', status: 'progress' })
21
22 const response = await fetch('/api/progress', {
23 method: 'POST',
24 headers: { 'Content-Type': 'application/json' },
25 body: JSON.stringify({ /* request data */ })
26 })
27
28 if (!response.body) {
29 setState({ progress: 0, message: 'No response body', status: 'error' })
30 return
31 }
32
33 const reader = response.body.getReader()
34 const decoder = new TextDecoder()
35 let buffer = ''
36
37 while (true) {
38 const { done, value } = await reader.read()
39 if (done) break
40
41 buffer += decoder.decode(value, { stream: true })
42
43 // Parse SSE messages from buffer
44 const lines = buffer.split('\n\n')
45 buffer = lines.pop() || '' // Keep incomplete message in buffer
46
47 for (const line of lines) {
48 if (line.startsWith('data: ')) {
49 const data = JSON.parse(line.slice(6))
50 setState({
51 progress: data.progress,
52 message: data.message,
53 status: data.status
54 })
55 }
56 }
57 }
58 }
59
60 return (
61 <div>
62 <button onClick={startProcess} disabled={state.status === 'progress'}>
63 Start Process
64 </button>
65 <progress value={state.progress} max={100} />
66 <p>{state.message}</p>
67 </div>
68 )
69}
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
typescript
1// Server
2async function sendNamedEvent(event: string, data: object) {
3 await writer.write(encoder.encode(
4 `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
5 ))
6}
7
8// Send different event types
9await sendNamedEvent('progress', { percent: 50 })
10await sendNamedEvent('log', { message: 'Processing...' })
11await sendNamedEvent('complete', { result: 'success' })
typescript
1// Client with EventSource (for GET endpoints)
2const source = new EventSource('/api/events')
3
4source.addEventListener('progress', (e) => {
5 const data = JSON.parse(e.data)
6 console.log('Progress:', data.percent)
7})
8
9source.addEventListener('complete', (e) => {
10 const data = JSON.parse(e.data)
11 console.log('Done:', data.result)
12 source.close()
13})
Vercel Considerations
Critical: Background Task Termination
<EXTREMELY-IMPORTANT>
On Vercel, SSE works differently than traditional servers. The function stays alive while the stream is open, but you must manage the lifecycle carefully.
</EXTREMELY-IMPORTANT>
typescript
1// CORRECT: Stream keeps function alive until closed
2;(async () => {
3 try {
4 // Do all work here
5 for (const item of items) {
6 await processItem(item)
7 await sendSSE({ progress: calculateProgress(item) })
8 }
9 await sendSSE({ status: 'complete' })
10 } finally {
11 await writer.close() // MUST close to end function
12 }
13})()
14
15return new Response(stream.readable, { headers })
Timeout Limits
| Plan | Max Duration |
|---|
| Hobby | 10 seconds |
| Pro | 60 seconds |
| Enterprise | 900 seconds |
For long operations, consider:
- Breaking into smaller chunks
- Using background jobs (Vercel Cron, external queue)
- Providing estimated time warnings
Progress Patterns
Percentage-Based
typescript
1interface PercentageProgress {
2 progress: number // 0-100
3 message: string
4 status: 'progress' | 'complete' | 'error'
5}
6
7// Calculate progress for known item count
8const total = items.length
9for (let i = 0; i < items.length; i++) {
10 await processItem(items[i])
11 await sendSSE({
12 progress: Math.floor(((i + 1) / total) * 100),
13 message: `Processing ${i + 1} of ${total}...`,
14 status: 'progress'
15 })
16}
Phase-Based
typescript
1interface PhaseProgress {
2 phase: string
3 phaseProgress: number
4 overallProgress: number
5 message: string
6}
7
8const phases = [
9 { name: 'validate', weight: 10 },
10 { name: 'process', weight: 70 },
11 { name: 'finalize', weight: 20 },
12]
13
14// Track progress across phases
15let overallProgress = 0
16for (const phase of phases) {
17 await sendSSE({
18 phase: phase.name,
19 phaseProgress: 0,
20 overallProgress,
21 message: `Starting ${phase.name}...`
22 })
23
24 // Do phase work...
25
26 overallProgress += phase.weight
27}
Indeterminate with Logs
typescript
1interface LogProgress {
2 type: 'log' | 'warning' | 'error' | 'complete'
3 message: string
4 timestamp: string
5}
6
7// For operations where progress can't be calculated
8await sendSSE({
9 type: 'log',
10 message: 'Connecting to external service...',
11 timestamp: new Date().toISOString()
12})
Error Handling
Server-Side
typescript
1;(async () => {
2 try {
3 await riskyOperation()
4 await sendSSE({ status: 'complete', message: 'Done!' })
5 } catch (error) {
6 // Always send error to client before closing
7 await sendSSE({
8 status: 'error',
9 message: error instanceof Error ? error.message : 'Unknown error',
10 progress: 0
11 })
12 } finally {
13 // ALWAYS close the stream
14 await writer.close()
15 }
16})()
Client-Side
typescript
1async function consumeSSE(url: string, body: object) {
2 try {
3 const response = await fetch(url, {
4 method: 'POST',
5 body: JSON.stringify(body)
6 })
7
8 if (!response.ok) {
9 throw new Error(`HTTP ${response.status}`)
10 }
11
12 // Process stream...
13 } catch (error) {
14 // Handle network errors, aborts, etc.
15 if (error.name === 'AbortError') {
16 console.log('Request cancelled')
17 } else {
18 console.error('SSE failed:', error)
19 }
20 }
21}
Cancellation
typescript
1// Client with AbortController
2const controller = new AbortController()
3
4const response = await fetch('/api/progress', {
5 method: 'POST',
6 body: JSON.stringify(data),
7 signal: controller.signal
8})
9
10// Cancel button handler
11function handleCancel() {
12 controller.abort()
13}
Real-World Example: Batch Processing
typescript
1// app/api/batch/route.ts
2export async function POST(req: NextRequest) {
3 const { items } = await req.json()
4
5 const stream = new TransformStream()
6 const writer = stream.writable.getWriter()
7 const encoder = new TextEncoder()
8
9 const sendSSE = async (data: object) => {
10 await writer.write(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
11 }
12
13 ;(async () => {
14 const results = { success: 0, failed: 0, errors: [] as string[] }
15
16 try {
17 await sendSSE({
18 status: 'progress',
19 progress: 0,
20 message: `Processing ${items.length} items...`
21 })
22
23 for (let i = 0; i < items.length; i++) {
24 try {
25 await processItem(items[i])
26 results.success++
27 } catch (err) {
28 results.failed++
29 results.errors.push(`Item ${i}: ${err.message}`)
30 }
31
32 await sendSSE({
33 status: 'progress',
34 progress: Math.floor(((i + 1) / items.length) * 100),
35 message: `Processed ${i + 1} of ${items.length}`,
36 results: { ...results }
37 })
38 }
39
40 await sendSSE({
41 status: 'complete',
42 progress: 100,
43 message: `Complete: ${results.success} succeeded, ${results.failed} failed`,
44 results
45 })
46 } catch (error) {
47 await sendSSE({
48 status: 'error',
49 message: error instanceof Error ? error.message : 'Batch failed',
50 results
51 })
52 } finally {
53 await writer.close()
54 }
55 })()
56
57 return new Response(stream.readable, {
58 headers: {
59 'Content-Type': 'text/event-stream',
60 'Cache-Control': 'no-cache',
61 'Connection': 'keep-alive',
62 }
63 })
64}
Quick Reference
typescript
1// Minimal SSE endpoint
2export async function POST(req: NextRequest) {
3 const stream = new TransformStream()
4 const writer = stream.writable.getWriter()
5 const encoder = new TextEncoder()
6
7 ;(async () => {
8 await writer.write(encoder.encode(`data: {"message":"Hello"}\n\n`))
9 await writer.close()
10 })()
11
12 return new Response(stream.readable, {
13 headers: { 'Content-Type': 'text/event-stream' }
14 })
15}
typescript
1// Minimal client consumption
2const res = await fetch('/api/sse', { method: 'POST' })
3const reader = res.body!.getReader()
4const decoder = new TextDecoder()
5
6while (true) {
7 const { done, value } = await reader.read()
8 if (done) break
9 console.log(decoder.decode(value))
10}
Resources