A desktop app talks to a receipt printer by sending it raw ESC/POS bytes over USB, a serial port, or a TCP socket on port 9100. The cash drawer usually plugs into the printer, so the app opens it with one more printer command, and most barcode scanners need no code at all because they type into the app like a keyboard.
Who this is for: teams building a point of sale, kiosk, or warehouse app in Electron, Tauri, or .NET that has to drive real retail hardware. Who should look elsewhere: if you’re on Square, Shopify POS, or Lightspeed, their hardware lists already cover this. Our custom POS or Square guide helps with that choice first.
How we wrote this: we haven’t filmed a demo on real hardware for this post. Everything below comes from Epson, Microsoft, Zebra, Electron, and Tauri documentation, linked where it’s used. We did run the printer code in Node, Rust, and C# against a fake network printer on our own machine, and all three sent the same bytes. That proves the bytes, not the printer. Test on the exact model you’ll ship.
The data flow in 52 seconds
Transcript (the video has no sound; this is every line on screen)
0:00. Receipt printer, scanner, and cash drawer. How a desktop app talks to each one. A diagram of the data flow, not a hardware recording.
0:05. 1. Barcode scanner: by default it types, like a keyboard. Top row: a USB scanner in keyboard mode sends “4006381333931” and Enter as keystrokes. The operating system sees keyboard input, and it lands in the focused text box of your app. Bottom row: a scanner in HID POS or serial mode sends to a device port (HID or COM), and your app reads the port no matter which window has focus. Keyboard mode needs no code but needs focus. Switch the scanner to HID POS or serial mode when the app must know the input came from the scanner.
0:16. 2. Receipt printer: the app sends raw ESC/POS bytes. Your app builds the receipt and sends “1B 40 … 1D 56 42 00” to the printer over USB, serial, or TCP 9100. 1B 40 is ESC @, initialize the printer. Then the text, each line ending in 0A. 1D 56 42 00 is GS V 66 0, feed to the cutter and cut. No driver page layout. The bytes go straight to the printer over USB, a serial port, or a raw TCP socket on port 9100.
0:28. 3. Cash drawer: the printer opens the drawer. Your app sends “1B 70 00 3C 78” to the printer. That’s ESC p 0 60 120: pin 2, 120 ms on, 240 ms off. The printer pulses its drawer kick port and the drawer opens. Most drawers have no data line of their own. They plug into the printer’s drawer kick port, and the printer sends the pulse.
0:38. 4. Your framework: same bytes, different plumbing. Electron: Node serialport in the main process, or Web Serial, WebHID, or WebUSB in the window. Tauri: a Rust command (serialport crate or TCP), called with invoke(). .NET: SerialPort, TcpClient, or OPOS and PointOfService drivers. Keep hardware code in one small module behind a plain interface: print(receipt), openDrawer(), onScan(code).
0:48. Test on the exact printer model you will ship. dawloom.com
Step 1: find out how each device is connected
Before writing code, list each device and its connection. It decides everything else.
| Device | Common connections | What your app sends or reads |
|---|---|---|
| Receipt printer | USB, serial (RS-232 or USB-serial), Ethernet or Wi-Fi, Bluetooth | ESC/POS bytes out, status bytes in |
| Barcode scanner | USB keyboard mode (default on many), USB HID POS, USB serial (CDC), Bluetooth | Keystrokes, or a barcode string from a port |
| Cash drawer | The printer’s drawer kick (DK) port, sometimes network or Bluetooth | Nothing direct. The printer pulses it |
| Customer display | Serial or USB, often through OPOS | Text lines |
Microsoft’s Point of Service hardware support page describes the same layout. For drawers: “Cash drawers that do not have network or Bluetooth capabilities can be connected via the DK port on a supported Receipt Printer.” For printers: “Windows supports network and Bluetooth connected receipt printers using the Epson ESC/POS printer control language.”
Step 2: build the receipt as ESC/POS bytes
ESC/POS is Epson’s printer command language, and most thermal receipt printers accept it or an emulation of it. You don’t send a page. You send text plus a few control bytes. These three come from Epson’s own FAQ for ESC/POS (an Epson document) and its online command reference:
| Command | Hex | What Epson says it does |
|---|---|---|
| ESC @ | 1B 40 | ”Clears the data in the print buffer and resets the printer modes to the modes that were in effect when the power was turned on.” |
| GS V 66 n | 1D 56 42 n | ”Feeds paper to (cutting position + n × vertical motion unit) and executes a partial cut (one point left uncut).” |
| ESC p m t1 t2 | 1B 70 m t1 t2 | ”Outputs the pulse specified by t1 and t2 to connector pin m to open the cash drawer.” m = 0 is pin 2, m = 1 is pin 5. “t1 specifies the pulse ON time as [t1 × 2 ms].” |
Epson’s own sample program opens the drawer with ESC p 0 60 120, so 120 ms on and 240 ms off. Some drawers need a different pulse length, so check the drawer’s manual before you hard-code it.
Here’s a small builder in JavaScript. It works in Electron’s main process or in Node:
// escpos.mjs
// ESC/POS bytes for a plain receipt. ASCII text only: printers use their own
// code pages, so map accented characters before sending.
const ESC = 0x1b, GS = 0x1d, LF = 0x0a;
export function receipt(lines) {
const bytes = [ESC, 0x40]; // ESC @ : initialize
for (const line of lines) {
for (const ch of line) bytes.push(ch.charCodeAt(0) < 128 ? ch.charCodeAt(0) : 0x3f); // "?" for non-ASCII
bytes.push(LF);
}
bytes.push(GS, 0x56, 66, 0); // GS V 66 0 : feed to the cutter, partial cut
return Uint8Array.from(bytes);
}
// ESC p 0 60 120 : pulse drawer pin 2, 120 ms on, 240 ms off
export const OPEN_DRAWER = Uint8Array.from([ESC, 0x70, 0, 60, 120]);
Character sets are the first thing that breaks in real shops. Printers don’t read UTF-8 by default. They use code pages, selected with another ESC/POS command, and the right one depends on the model and the language. Treat any non-ASCII text as a test case.
Step 3: send the bytes
Network printers: TCP port 9100
A network receipt printer listens for raw bytes on port 9100. Epson’s UB-E02 Ethernet interface guide says: “It is possible to control the printer directly by an application with the ESC/POS commands through writing and reading to the TCP PORT9100.” No driver is needed, which makes this the easiest connection to support across Windows, macOS, and Linux.
Electron or Node:
import net from 'node:net';
import { receipt, OPEN_DRAWER } from './escpos.mjs';
export function sendToPrinter(host, bytes, port = 9100) {
return new Promise((resolve, reject) => {
const sock = net.createConnection({ host, port }, () => sock.end(bytes));
sock.setTimeout(5000, () => sock.destroy(new Error('printer timeout')));
sock.on('close', (hadError) => (hadError ? null : resolve()));
sock.on('error', reject);
});
}
await sendToPrinter('192.168.1.50', receipt(['Order 1042', 'Total 12.50']));
await sendToPrinter('192.168.1.50', OPEN_DRAWER);
In Electron, put this in the main process and expose it to the window through ipcMain.handle and a preload script. The window shouldn’t open sockets itself.
Tauri (Rust): the standard library is enough for TCP.
use std::io::Write;
use std::net::TcpStream;
use std::time::Duration;
const ESC: u8 = 0x1b;
const GS: u8 = 0x1d;
pub fn receipt(lines: &[&str]) -> Vec<u8> {
let mut b = vec![ESC, 0x40];
for line in lines {
b.extend(line.bytes().map(|c| if c.is_ascii() { c } else { b'?' }));
b.push(0x0a);
}
b.extend([GS, 0x56, 66, 0]);
b
}
pub const OPEN_DRAWER: [u8; 5] = [ESC, 0x70, 0, 60, 120];
pub fn send_tcp(addr: &str, bytes: &[u8]) -> std::io::Result<()> {
let mut stream = TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_secs(5))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
stream.write_all(bytes)
}
Then wrap it in a command the window can call with invoke(), as in Tauri’s guide to calling Rust. Run the blocking write off the main thread:
#[tauri::command]
async fn print_receipt(addr: String, lines: Vec<String>) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
send_tcp(&addr, &receipt(&refs))
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
import { invoke } from '@tauri-apps/api/core';
await invoke('print_receipt', { addr: '192.168.1.50:9100', lines: ['Order 1042', 'Total 12.50'] });
.NET (C#):
using System.Net.Sockets;
using System.Text;
static byte[] Receipt(params string[] lines)
{
var b = new List<byte> { 0x1B, 0x40 }; // ESC @ : initialize
foreach (var line in lines)
{
b.AddRange(Encoding.ASCII.GetBytes(line)); // non-ASCII becomes '?'
b.Add(0x0A);
}
b.AddRange(new byte[] { 0x1D, 0x56, 66, 0 }); // GS V 66 0 : feed and cut
return b.ToArray();
}
static async Task SendTcpAsync(string host, byte[] bytes, int port = 9100)
{
using var client = new TcpClient();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await client.ConnectAsync(host, port, cts.Token);
await client.GetStream().WriteAsync(bytes, cts.Token);
}
We ran all three against a small Node script that listens on 127.0.0.1:9100 and logs what arrives. Each sent the same 29-byte receipt and the same 5-byte drawer pulse:
29 bytes: 1b 40 4f 72 64 65 72 20 31 30 34 32 0a 54 6f 74 61 6c 20 31 32 2e 35 30 0a 1d 56 42 00
5 bytes: 1b 70 00 3c 78
Serial and USB-serial printers
Many printers and older drawers show up as a COM port on Windows or /dev/ttyUSB0 on Linux. You write the same bytes to the port.
- Electron: Node SerialPort “is a JavaScript library for connecting to serial ports that works in NodeJS and Electron.”
new SerialPort({ path: 'COM3', baudRate: 9600 }), thenport.write(bytes). It’s a native module, so it must be rebuilt for your Electron version. - Tauri: none of the official Tauri plugins cover serial or USB. Use the Rust serialport crate in a command:
serialport::new("COM3", 9600).timeout(Duration::from_secs(5)).open()?andwrite_all. Community plugins exist, but check how active they are before you depend on one. - .NET:
System.IO.Ports.SerialPortships as a NuGet package on modern .NET.new SerialPort("COM3", 9600),Open(),Write(bytes, 0, bytes.Length).
Match the baud rate, parity, and flow control to the printer’s settings, or you’ll get garbage or nothing.
USB printers that install as a Windows printer
If a vendor driver installs the printer as a normal Windows printer, you can’t open it as a socket or COM port. Your options are raw printing through the Windows print spooler, switching the printer’s USB mode if the model allows it, or the vendor’s own SDK. This one depends most on the model, so read its manual before you pick.
Step 4: open the cash drawer
Send 1B 70 00 3C 78 to the printer, the same way you sent the receipt. The printer pulses its drawer kick port and the drawer opens. Most drawers have no data connection of their own, which is why the drawer only works when the printer is reachable.
Two details save support calls:
- Open the drawer after the sale is saved, not before. If the save fails, the drawer stays shut.
- Some printers report the drawer sensor in their status bytes, so you can show “drawer open” on screen. Which bit, and whether it exists, depends on the model.
Step 5: read the barcode scanner
Keyboard mode: zero code, one catch
Most USB scanners type like a keyboard out of the box. Zebra’s DS2208 guide marks “USB Keyboard HID” as the default USB mode (Zebra DS2208 reference guide). The scan arrives as keystrokes followed by Enter, in whatever text box has focus. To make test labels to scan, use our free barcode label generator.
That’s fine for a search box. It goes wrong when focus is elsewhere: the barcode lands in a note field, or a dialog eats the Enter. A common fix is to listen at the window level and treat a fast burst of keys ending in Enter as a scan:
// Keystrokes from a scanner arrive much faster than a person types.
// Collect keys that come within 30 ms of each other, ending in Enter.
let buffer = '';
let last = 0;
window.addEventListener('keydown', (e) => {
const now = performance.now();
if (now - last > 30) buffer = '';
last = now;
if (e.key === 'Enter' && buffer.length >= 6) {
onScan(buffer);
buffer = '';
e.preventDefault();
} else if (e.key.length === 1) {
buffer += e.key;
}
});
The 30 ms gap and the 6-character minimum are starting points, not tested values. Tune them on the scanners you’ll ship, and use a scanner suffix or prefix if the model supports one.
HID POS or serial mode: the app owns the scanner
Switch the scanner to HID POS or USB serial (CDC) mode, usually by scanning a setup barcode from its manual, and the scans stop being keystrokes. Your app reads them from a port, whatever window has focus.
- Windows, any framework that can call WinRT: Microsoft’s
Windows.Devices.PointOfServicehasBarcodeScanner,PosPrinter, andCashDrawer. Windows includes “an in-box class driver for USB connected barcode scanners which is based on the HID POS Scanner Usage Table (8c) specification”, and Microsoft lists tested Honeywell, Zebra, and other models (device support). - Electron: Chromium’s Web Serial and WebHID APIs work, with one difference from a browser. You must handle
select-serial-portorselect-hid-deviceon the session yourself, because Electron gives you APIs to “either automatically pick a device or prompt users to pick a device via a developer created interface” (Electron device access). - Tauri: the system webview on macOS and Linux is WebKit, and MDN marks Web Serial as “Limited availability” because it “does not work in some of the most widely-used browsers” (MDN). So read the port in Rust with the serialport crate and send scans to the window as events. Tauri’s official barcode scanner plugin is for phone cameras: “Allows your mobile application to use the camera to scan QR codes.”
- .NET:
SerialPortwith itsDataReceivedevent for CDC mode, orWindows.Devices.PointOfService.BarcodeScannerfor HID POS.
What about OPOS and UnifiedPOS?
UnifiedPOS (UPOS) is the industry standard API for POS devices, now published by the OMG. The latest version listed is 1.16.1. OPOS is its Windows COM version, and POS for .NET is Microsoft’s .NET version. POS for .NET v1.14.1 is “A fully compliant implementation of the Unified Point of Service (UnifiedPOS) v1.14 international standard”. It runs on .NET Framework, and Microsoft notes it “does not provide any support for the Windows Runtime (WinRT) API.”
Use OPOS when the hardware vendor ships an OPOS driver and your app is Windows-only, or when you must support a long list of models through one API. Microsoft’s PointOfService APIs also accept “any OPOS compatible receipt printers via OPOS service objects.” Skip it for cross-platform apps, because it’s Windows-only.
Which route we’d take
| If your app is | Printer | Drawer | Scanner |
|---|---|---|---|
| Electron, any OS | Network on 9100, or Node SerialPort | Through the printer | Keyboard mode first, Web Serial or WebHID if focus is a problem |
| Tauri, any OS | Network on 9100, or the serialport crate | Through the printer | Keyboard mode, or the serialport crate with events |
| .NET, Windows only | PointOfService or OPOS if drivers exist, otherwise TCP or SerialPort | PointOfService CashDrawer, or through the printer | PointOfService BarcodeScanner in HID POS mode |
Whatever the stack, keep the hardware code in one small module with three functions: print(receipt), openDrawer(), and onScan(code). When a store buys a different printer, you change one file.
Hardware work is part of how we build retail and POS systems and desktop apps. If you’re still picking a framework, Tauri or Electron in 2026 covers the trade-offs. Shipping the app on Windows also means signing it, and what code signing costs has the prices. Stores with more than one branch should also read Square alternatives for multi-location retailers.
FAQ
How do I connect a barcode scanner to my desktop app?
Plug it in. Most USB scanners act as a keyboard by default, so a scan types into the focused text box and presses Enter. If your app needs scans no matter where focus is, switch the scanner to HID POS or serial mode and read the port from your code.
How do I print to a thermal receipt printer from Electron?
Build ESC/POS bytes and send them from the main process. For a network printer, open a TCP socket to port 9100 with Node’s net module. For a USB-serial printer, use the serialport package. Don’t route receipts through the normal print dialog, which formats a page instead of sending printer commands.
How does a POS app open the cash drawer?
It sends ESC p (1B 70 00 3C 78 in Epson’s sample) to the receipt printer. The drawer is wired to the printer’s drawer kick port, and the printer sends the electrical pulse that releases it.
Can Tauri talk to USB and serial devices?
Yes, through Rust. Tauri has no official serial or USB plugin, so you call the serialport crate or a TCP socket inside a Tauri command and invoke it from the window. Web Serial isn’t a safe bet inside Tauri, because the macOS and Linux webviews are WebKit.
Do I need OPOS drivers?
Only on Windows, and only if you want one API across many vendors’ devices or the vendor’s features depend on it. Raw ESC/POS over TCP or serial works on every OS without drivers.