Web Serial API Implementation on Website
Web Serial API opens browser direct access to COM ports, USB devices with serial interface, and Bluetooth Serial Port Profile. Label printers, Arduino, industrial sensors, medical devices, POS terminals — all now accessible from web page without native apps, Java applets, or Electron. Just navigator.serial in the browser.
This is not a simple API. It's async, stream-based, requires explicit user gesture to select port, works exclusively in Secure Context (HTTPS or localhost). Implementation without understanding ReadableStream and WritableStream turns into overflowing promise buffers and frozen UI.
Support and Limitations
API supported in Chrome 89+, Edge 89+, Opera 75+. Firefox and Safari — not supported. This means page with Web Serial must either require Chromium browser or provide fallback UI (manual input, file upload).
Support check:
if (!('serial' in navigator)) {
throw new Error('Web Serial API not supported. Use Chrome 89+')
}
Origin permissions: in production add to headers:
Permissions-Policy: serial=*
Or restrict to specific origin:
Permissions-Policy: serial=(self "https://app.example.com")
Service Architecture
Isolate all port work in a class. UI component doesn't know about streams and buffers — it calls service methods and gets data via callbacks or EventEmitter.
type SerialDataHandler = (data: Uint8Array) => void
type SerialErrorHandler = (error: Error) => void
interface SerialConfig {
baudRate: number
dataBits?: 7 | 8
stopBits?: 1 | 2
parity?: 'none' | 'even' | 'odd'
bufferSize?: number
flowControl?: 'none' | 'hardware'
}
class SerialService extends EventTarget {
private port: SerialPort | null = null
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null
private writer: WritableStreamDefaultWriter<Uint8Array> | null = null
private readLoopActive = false
async requestPort(filters: SerialPortFilter[] = []): Promise<void> {
this.port = await navigator.serial.requestPort({ filters })
}
async connect(config: SerialConfig): Promise<void> {
if (!this.port) throw new Error('Port not selected')
await this.port.open({
baudRate: config.baudRate,
dataBits: config.dataBits ?? 8,
stopBits: config.stopBits ?? 1,
parity: config.parity ?? 'none',
bufferSize: config.bufferSize ?? 4096,
flowControl: config.flowControl ?? 'none',
})
this.writer = this.port.writable!.getWriter()
this.startReadLoop()
}
private async startReadLoop(): Promise<void> {
if (!this.port?.readable) return
this.readLoopActive = true
while (this.port.readable && this.readLoopActive) {
this.reader = this.port.readable.getReader()
try {
while (true) {
const { value, done } = await this.reader.read()
if (done) break
if (value) {
this.dispatchEvent(
Object.assign(new Event('data'), { detail: value })
)
}
}
} catch (error) {
if (this.readLoopActive) {
this.dispatchEvent(
Object.assign(new Event('error'), { detail: error })
)
}
} finally {
this.reader.releaseLock()
}
}
}
async write(data: Uint8Array | string): Promise<void> {
if (!this.writer) throw new Error('Port not open')
const bytes =
typeof data === 'string'
? new TextEncoder().encode(data)
: data
await this.writer.write(bytes)
}
async disconnect(): Promise<void> {
this.readLoopActive = false
this.reader?.cancel()
this.writer?.releaseLock()
await this.port?.close()
this.port = null
this.reader = null
this.writer = null
}
get isConnected(): boolean {
return this.port !== null && this.port.readable !== null
}
}
Protocol Handling
Most devices use text or binary protocols over UART. Example for request-response protocol with \r\n delimiters:
class LineProtocolAdapter {
private buffer = ''
private pendingResolvers: Array<(line: string) => void> = []
constructor(private serial: SerialService) {
serial.addEventListener('data', (e: Event) => {
const event = e as Event & { detail: Uint8Array }
this.buffer += new TextDecoder().decode(event.detail)
this.flushLines()
})
}
private flushLines(): void {
const lines = this.buffer.split('\r\n')
this.buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.trim()) {
const resolver = this.pendingResolvers.shift()
if (resolver) resolver(line.trim())
}
}
}
async sendCommand(command: string, timeoutMs = 2000): Promise<string> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingResolvers = this.pendingResolvers.filter(r => r !== resolve)
reject(new Error(`Timeout: no response to "${command}" in ${timeoutMs}ms`))
}, timeoutMs)
this.pendingResolvers.push((line) => {
clearTimeout(timer)
resolve(line)
})
this.serial.write(command + '\r\n').catch(reject)
})
}
}
// Usage:
const adapter = new LineProtocolAdapter(serialService)
const version = await adapter.sendCommand('VERSION')
const sensorData = await adapter.sendCommand('READ SENSOR 1')
Automatic Reconnect
Devices disconnect. USB cables get pulled. Need reconnect:
navigator.serial.addEventListener('connect', (event) => {
const port = (event as Event & { target: SerialPort }).target
console.log('Device connected:', port.getInfo())
// Check if our port and reconnect
})
navigator.serial.addEventListener('disconnect', (event) => {
const port = (event as Event & { target: SerialPort }).target
if (port === serialService.currentPort) {
serialService.handleDisconnect()
}
})
Request by USB Vendor/Product ID
To avoid showing all available ports, show only needed device:
// List of known devices
const DEVICE_FILTERS: SerialPortFilter[] = [
{ usbVendorId: 0x2341 }, // Arduino
{ usbVendorId: 0x0483, usbProductId: 0x5740 }, // STM32 Virtual COM
{ usbVendorId: 0x10C4, usbProductId: 0xEA60 }, // CP2102 (Silicon Labs)
{ usbVendorId: 0x0403, usbProductId: 0x6001 }, // FTDI FT232
]
await serialService.requestPort(DEVICE_FILTERS)
Save Selected Port
After first requestPort user grants permission. On page re-open can get port without new dialog:
async function autoConnect(config: SerialConfig): Promise<boolean> {
const ports = await navigator.serial.getPorts()
if (ports.length === 0) return false
// Take first authorized port (or filter by getInfo())
serialService.port = ports[0]
await serialService.connect(config)
return true
}
React Hook
function useSerialPort(config: SerialConfig) {
const serviceRef = useRef(new SerialService())
const [connected, setConnected] = useState(false)
const [lastData, setLastData] = useState<Uint8Array | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const service = serviceRef.current
const onData = (e: Event) => {
setLastData((e as any).detail)
}
const onError = (e: Event) => {
setError((e as any).detail?.message ?? 'Port error')
setConnected(false)
}
service.addEventListener('data', onData)
service.addEventListener('error', onError)
return () => {
service.removeEventListener('data', onData)
service.removeEventListener('error', onError)
}
}, [])
const connect = useCallback(async () => {
try {
setError(null)
await serviceRef.current.requestPort()
await serviceRef.current.connect(config)
setConnected(true)
} catch (e) {
setError(e instanceof Error ? e.message : 'Connection failed')
}
}, [config])
const disconnect = useCallback(async () => {
await serviceRef.current.disconnect()
setConnected(false)
}, [])
const write = useCallback((data: Uint8Array | string) => {
return serviceRef.current.write(data)
}, [])
return { connected, lastData, error, connect, disconnect, write }
}
What's Involved
Analyze target device protocol, configure port parameters (baud rate, parity, flow control), implement SerialService and protocol adapter classes, React hook or Vue composable, handle reconnects, error handling, connection status UI.
If device uses proprietary binary protocol — additional time for reverse-engineering or documentation study.
Timeline: 2–4 days depending on device protocol complexity.







