<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Cyrille Sepele</title>
    <description>The latest articles on DEV Community by Cyrille Sepele (@sepcy).</description>
    <link>https://dev.to/sepcy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3889620%2F7478edcd-5cf2-4d7e-b8b2-3bbce474ea8d.png</url>
      <title>DEV Community: Cyrille Sepele</title>
      <link>https://dev.to/sepcy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sepcy"/>
    <language>en</language>
    <item>
      <title>GitLab Runner Has Never Contacted This Instance: Every Cause and Fix</title>
      <dc:creator>Cyrille Sepele</dc:creator>
      <pubDate>Tue, 08 Sep 2026 19:17:42 +0000</pubDate>
      <link>https://dev.to/sepcy/gitlab-runner-has-never-contacted-this-instance-every-cause-and-fix-ne1</link>
      <guid>https://dev.to/sepcy/gitlab-runner-has-never-contacted-this-instance-every-cause-and-fix-ne1</guid>
      <description>&lt;p&gt;You created a runner, GitLab listed it, and next to it sits a grey warning:&lt;br&gt;
&lt;em&gt;This runner has never contacted this instance.&lt;/em&gt; Your pipeline sits pending, and nothing in the UI tells you why.&lt;/p&gt;

&lt;p&gt;The good news is that this message is far more specific than it looks. It rules out most of what you might be about to check.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the message actually means
&lt;/h2&gt;

&lt;p&gt;GitLab runners poll. The runner process asks GitLab for work every few seconds (&lt;code&gt;check_interval&lt;/code&gt;, three seconds by default). GitLab never pushes anything to the runner, and it never opens a connection to it.&lt;/p&gt;

&lt;p&gt;So this warning means exactly one thing: a runner record exists in GitLab's database, and no runner process has ever polled it.&lt;/p&gt;

&lt;p&gt;That splits the problem cleanly in two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The runner record was created. Your token was valid at creation time, and GitLab can see the runner exists.&lt;/li&gt;
&lt;li&gt;No process has ever reached the API. Either nothing is running, or something is running and cannot get to GitLab.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every cause below is a variation on the second half. Notably, none of them are about your &lt;code&gt;.gitlab-ci.yml&lt;/code&gt;, your tags, or your job configuration. Those cause different symptoms. Don't start there.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cause 0: you haven't installed the runner yet
&lt;/h2&gt;

&lt;p&gt;Since GitLab 16, you create a runner in the UI first, and it appears in the list immediately with an authentication token starting with &lt;code&gt;glrt-&lt;/code&gt;. The runner row exists before any machine has been told about it.&lt;/p&gt;

&lt;p&gt;This is by far the most common reason for the warning, and it is not a bug. The UI is showing you a runner you created but haven't installed. If you stopped after copying the token, that's the whole story.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cause 1: the process isn't running
&lt;/h2&gt;

&lt;p&gt;Check before assuming anything more interesting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status gitlab-runner    &lt;span class="c"&gt;# package install&lt;/span&gt;
docker ps &lt;span class="nt"&gt;-a&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;gitlab-runner      &lt;span class="c"&gt;# container install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A container that exited seconds after starting almost always means a malformed &lt;code&gt;config.toml&lt;/code&gt;. Read the logs rather than restarting it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; gitlab-runner &lt;span class="nt"&gt;-n&lt;/span&gt; 50 &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
docker logs gitlab-runner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cause 2: the service reads a different config.toml
&lt;/h2&gt;

&lt;p&gt;This one wastes the most time, because everything looks correct.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gitlab-runner register&lt;/code&gt; writes to a config file that depends on which user ran it. The service reads a path that depends on how it was installed. When those disagree, registration genuinely succeeded and the running service genuinely never learned about it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How you ran it&lt;/th&gt;
&lt;th&gt;Config file used&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo gitlab-runner register&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/etc/gitlab-runner/config.toml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gitlab-runner register&lt;/code&gt; as your user&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/.gitlab-runner/config.toml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;td&gt;whatever you mounted, often &lt;code&gt;/srv/gitlab-runner/config&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Confirm which file the runner actually loaded, and confirm your runner is in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'\[\[runners\]\]'&lt;/span&gt; /etc/gitlab-runner/config.toml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero means you registered into a different file. Re-register with &lt;code&gt;sudo&lt;/code&gt;, or point the service at the file you already wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 3: the runner can't reach your GitLab URL
&lt;/h2&gt;

&lt;p&gt;Registration and polling are the same API, so if registration worked from that&lt;br&gt;
machine, the network path worked at least once. That makes this cause more likely when someone else registered the runner, when you copied a &lt;code&gt;config.toml&lt;/code&gt; between hosts, or when the runner lives in a different network segment.&lt;/p&gt;

&lt;p&gt;Test from the runner itself, not from your laptop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-v&lt;/span&gt; https://gitlab.example.com/api/v4/version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For self-managed instances, the usual failure is a URL that only resolves in some networks. An internal hostname, a VPN-only address, or a private IP will work from your desk and fail from a runner in a different subnet or a cloud region. The &lt;code&gt;url&lt;/code&gt; in &lt;code&gt;config.toml&lt;/code&gt; has to be reachable from the runner, which is not always the same address you type into your browser.&lt;/p&gt;

&lt;p&gt;Also check outbound egress. Plenty of hardened networks allow inbound HTTPS to&lt;br&gt;
GitLab and quietly drop outbound traffic from build hosts.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cause 4: TLS
&lt;/h2&gt;

&lt;p&gt;Self-signed certificates and private CAs fail here constantly, and the error in the runner log is often a single line about certificate verification that scrolls past.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;gitlab-runner &lt;span class="nt"&gt;--debug&lt;/span&gt; run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it in the foreground and watch. If you see a certificate error, give the runner the CA explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[runners]]&lt;/span&gt;
  &lt;span class="py"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://gitlab.example.com/"&lt;/span&gt;
  &lt;span class="py"&gt;tls-ca-file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/etc/gitlab-runner/certs/ca.crt"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file has to be readable by the user the service runs as, which is often not the user that created it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 5: the token isn't what you think
&lt;/h2&gt;

&lt;p&gt;A few variations, all of which produce a runner record that never polls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The authentication token was copied from a different runner, so the process
is happily polling as a runner you're not looking at&lt;/li&gt;
&lt;li&gt;The token was revoked, or the runner was deleted and recreated in the UI, and
the machine still holds the old one&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;--url&lt;/code&gt; was passed during a non-interactive registration, so the runner defaulted to &lt;code&gt;https://gitlab.com/&lt;/code&gt; and is contacting the wrong instance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is worth checking early on self-managed setups. Open&lt;br&gt;
&lt;code&gt;config.toml&lt;/code&gt; and read the &lt;code&gt;url&lt;/code&gt; line before anything else.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fast diagnostic order
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Rules out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Is it installed at all?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl status gitlab-runner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cause 0, 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is your runner in the config?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;grep -c '\[\[runners\]\]' /etc/gitlab-runner/config.toml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cause 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the URL right?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;grep url /etc/gitlab-runner/config.toml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cause 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can it reach GitLab?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;curl -v &amp;lt;url&amp;gt;/api/v4/version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cause 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does the token still work?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo gitlab-runner verify&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cause 4, 5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;gitlab-runner verify&lt;/code&gt; is the fastest single test. It performs the same authenticated call that polling uses, and it prints the failure instead of burying it in a log.&lt;/p&gt;
&lt;h2&gt;
  
  
  When verify says "is valid" and GitLab still says never contacted
&lt;/h2&gt;

&lt;p&gt;This is the combination that wastes the most time, because the one command everyone reaches for comes back clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Verifying runner... is valid                        runner=AbCdEfGh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the runners page still says the runner has never contacted the instance.&lt;/p&gt;

&lt;p&gt;Nothing is lying to you. &lt;code&gt;verify&lt;/code&gt; and polling are not the same test. &lt;code&gt;verify&lt;/code&gt; proves that a token in a config file is accepted by a GitLab that this shell can reach. Polling additionally requires that a &lt;strong&gt;service&lt;/strong&gt; is alive, that it reads the &lt;strong&gt;same&lt;/strong&gt; config file, and that the runner is not paused. Verify covers the credentials. It says nothing about the daemon.&lt;/p&gt;

&lt;p&gt;So when both are true at once, check these, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the service actually running?&lt;/strong&gt; &lt;code&gt;sudo gitlab-runner verify&lt;/code&gt; works fine with the service stopped, because you are the one making the call. &lt;code&gt;systemctl status gitlab-runner&lt;/code&gt; is the check that matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did verify read the config the service uses?&lt;/strong&gt; This is the trap. Run with &lt;code&gt;sudo&lt;/code&gt;, verify reads &lt;code&gt;/etc/gitlab-runner/config.toml&lt;/code&gt;. Run as your own user, it reads &lt;code&gt;~/.gitlab-runner/config.toml&lt;/code&gt;. If you registered without &lt;code&gt;sudo&lt;/code&gt; and
the service runs as root, you just verified a token the daemon has never seen. &lt;code&gt;sudo gitlab-runner --config /etc/gitlab-runner/config.toml verify&lt;/code&gt; settles it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the runner paused in the UI?&lt;/strong&gt; A paused runner verifies perfectly and
is never given a job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the token for the runner you're looking at?&lt;/strong&gt; Verify confirms &lt;em&gt;a&lt;/em&gt; runner
is valid, and prints its short token so you can tell which. If that token doesn't match the one on the runner page you have open, the process is polling happily as a different record.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The general rule: &lt;code&gt;verify&lt;/code&gt; failing tells you something useful. &lt;code&gt;verify&lt;/code&gt; passing only rules out causes 4 and 5, and sends you back to causes 1 and 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why self-managed instances get hit hardest
&lt;/h2&gt;

&lt;p&gt;On GitLab.com, the URL is public, the certificate is valid, and only one side of the connection is yours. Most of this list collapses to "is the runner running."&lt;/p&gt;

&lt;p&gt;On a self-managed instance, you own both ends. The reverse proxy in front of GitLab, the &lt;code&gt;external_url&lt;/code&gt; it was configured with, the certificate chain, the DNS split between internal and external views, and the egress rules on the build host are all yours to get right, and they only have to disagree slightly before the runner goes quiet without a useful error.&lt;/p&gt;

&lt;p&gt;That's the real cost of self-hosting, and it rarely shows up in the plan. The server is cheap. The afternoon spent proving that a runner can reach your own&lt;br&gt;
GitLab is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Work from the message outward. It already told you registration succeeded, so skip your CI config and check the five things above in order. Most of the time it's cause 0 or cause 2, and both are five-minute fixes once you stop looking at the pipeline.&lt;/p&gt;

&lt;p&gt;If you'd rather not do this again, RocketRunner provisions the machine, installs the runner, and connects it to your GitLab for you, including self-managed instances. Dedicated, isolated, and destroyed when you delete it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rocketrunner.io/" rel="noopener noreferrer"&gt;Start your free trial&lt;/a&gt; and skip the config.toml archaeology.&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>cicd</category>
      <category>devops</category>
      <category>troubleshooting</category>
    </item>
    <item>
      <title>ESC/POS emulator: preview and debug receipts without a printer</title>
      <dc:creator>Cyrille Sepele</dc:creator>
      <pubDate>Tue, 08 Sep 2026 12:53:18 +0000</pubDate>
      <link>https://dev.to/sepcy/escpos-emulator-preview-and-debug-receipts-without-a-printer-4d7e</link>
      <guid>https://dev.to/sepcy/escpos-emulator-preview-and-debug-receipts-without-a-printer-4d7e</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I work on ESCPost and Receiptful, both mentioned below. ESCPost is Apache-2.0 and needs no account, so you can check every claim here yourself.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you have ever wired up a thermal printer, you know the loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Move a column.&lt;/li&gt;
&lt;li&gt;Print.&lt;/li&gt;
&lt;li&gt;Squint at 58mm of paper.&lt;/li&gt;
&lt;li&gt;The total is off, or the euro sign came out as &lt;code&gt;?&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Go back to 1.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every round costs paper, a walk to the printer, and about ninety seconds. And when it comes out wrong, you still do not know why. You never see the bytes.&lt;/p&gt;

&lt;p&gt;Two things fixed this for me. Both run on your machine, and neither needs a printer plugged in. They are part of &lt;a href="https://github.com/receiptful/escpost" rel="noopener noreferrer"&gt;ESCPost&lt;/a&gt;, our open-source Rust CLI. Apache-2.0, no account, works offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draw the bytes on screen
&lt;/h2&gt;

&lt;p&gt;ESC/POS is just bytes. If you know the printer's geometry, you can draw them on screen instead of on paper.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;escpost render receipt.hex &lt;span class="nt"&gt;--profile&lt;/span&gt; REFERENCE &lt;span class="nt"&gt;--output-dir&lt;/span&gt; renderings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Freceiptful.io%2Fimg%2Fescpost-render-sheets.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Freceiptful.io%2Fimg%2Fescpost-render-sheets.png" title="Drawn at the printer's own dot resolution" alt="Two rendered receipt sheets side by side: an order receipt with a cafe logo, item columns and a large total, and a voucher sheet with a QR code" width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Logo, header, item columns, a double-height total, then a voucher with a QR code. It is drawn at the printer's own dot resolution, so the spacing you see is the spacing you get.&lt;/p&gt;

&lt;p&gt;Two files came out because the job has a cut in it. You get one PNG per sheet plus a manifest listing them.&lt;/p&gt;

&lt;p&gt;It reads stdin too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;generate-receipt | escpost render - &lt;span class="nt"&gt;--profile&lt;/span&gt; REFERENCE &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; receipt.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your receipt code has a visual test. Three seconds a round instead of ninety. And since the render is deterministic, you can commit those PNGs and diff them in CI. That catches the refactor that shifted your tax column, before it reaches two hundred shops.&lt;/p&gt;

&lt;h2&gt;
  
  
  Become the printer
&lt;/h2&gt;

&lt;p&gt;Most of the time the interesting bytes come from software you did not write. An ERP, a POS suite, some legacy Windows thing. You cannot see what it sends.&lt;/p&gt;

&lt;p&gt;Nearly all of them print over RAW TCP on port 9100. So pretend to be the printer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;escpost serve &lt;span class="nt"&gt;--listen&lt;/span&gt; 127.0.0.1:9100 &lt;span class="nt"&gt;--web-listen&lt;/span&gt; 127.0.0.1:9000 &lt;span class="nt"&gt;--profile&lt;/span&gt; REFERENCE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point the application at that address and hit print. Nothing reaches paper. The job opens in your browser instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Freceiptful.io%2Fimg%2Fescpost-workbench-jobs.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Freceiptful.io%2Fimg%2Fescpost-workbench-jobs.jpg" title="Sheets on the left, decoded commands on the right" alt="The ESCPost workbench showing both rendered sheets on the left and the decoded ESC/POS command trace on the right, with byte offsets" width="800" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sheets on one side, the decoded commands on the other, in order, with byte offsets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Down to the single character
&lt;/h3&gt;

&lt;p&gt;Each text command shows the raw bytes, with the character each one printed underneath.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Freceiptful.io%2Fimg%2Fescpost-character-trace.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Freceiptful.io%2Fimg%2Fescpost-character-trace.png" title="Point at a byte, see the character it printed" alt="A rendered receipt with the letter F boxed and labelled 46, next to the command trace showing the bytes 43 41 46 45 above the characters C A F E" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Point at a byte and ESCPost boxes that exact character on the paper and labels it with its hex value. Above, byte &lt;code&gt;46&lt;/code&gt; is the &lt;code&gt;F&lt;/code&gt; in &lt;code&gt;CAFE&lt;/code&gt;. It works the other way round too. Point at a command and it outlines everything that command painted.&lt;/p&gt;

&lt;p&gt;It even works for characters you cannot see. Point at a &lt;code&gt;20&lt;/code&gt; and it boxes the blank space the printer held open for it.&lt;/p&gt;

&lt;p&gt;The chips under each run show the printer state at that moment: font, bold, underline, alignment, character size, and the code page in use. So when the euro sign prints as &lt;code&gt;?&lt;/code&gt;, you stop guessing. Find the byte, read the code page sitting next to it, and you have your answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the right profile
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;REFERENCE&lt;/code&gt; is a generic virtual printer. Real ones differ in ways that break layouts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Freceiptful.io%2Fimg%2Fescpost-profiles-catalog.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Freceiptful.io%2Fimg%2Fescpost-profiles-catalog.jpg" title="32 profiles, and the geometry that reshapes your layout" alt="The ESCPost profile catalog listing printer profiles with vendor, model, paper width, printable width, dots, DPI, and barcode and QR support" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Printable width runs from 48mm to 80mm, or 384 to 640 dots. Columns tuned for one width reflow on another.&lt;/li&gt;
&lt;li&gt;Most printers are 203 dpi. The Epson TM-T88 series is 180.&lt;/li&gt;
&lt;li&gt;The wrong code page quietly corrupts currency symbols and accented names. Your test data was probably all ASCII, so you find out in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Render against the profile you actually ship to, and the surprise happens on your laptop instead of in the shop.&lt;/p&gt;

&lt;p&gt;One limit worth stating: previews show geometry and dot placement, not paper texture or a vendor's exact built-in glyphs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;receiptful/tap/escpost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are no bottles yet, so it compiles from source on the first install. Linux x86-64 is verified end to end. macOS and Windows builds exist but have not been verified natively yet, and I would rather say so than let you find out.&lt;/p&gt;

&lt;p&gt;Source and docs: &lt;a href="https://github.com/receiptful/escpost" rel="noopener noreferrer"&gt;github.com/receiptful/escpost&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One ask
&lt;/h2&gt;

&lt;p&gt;The catalog has 32 profiles. Exactly one, the Netum NT-5890K, is calibrated against a real printer. Thirty are synthesized from vendor datasheets, which are often optimistic, and the last is the synthetic &lt;code&gt;REFERENCE&lt;/code&gt;. The catalog marks them: &lt;code&gt;✓&lt;/code&gt; calibrated, &lt;code&gt;~&lt;/code&gt; synthesized, &lt;code&gt;○&lt;/code&gt; virtual. So you always know which is which.&lt;/p&gt;

&lt;p&gt;No team can buy every thermal printer. But between all of us, we already own them. If you have one on your desk, calibrating it turns a guess into something everyone shipping to that model can rely on.&lt;/p&gt;




&lt;p&gt;ESCPost covers the local half: rendering, capturing, and debugging on your own machine. When receipts need to reach a printer in a shop you cannot SSH into, that is what &lt;a href="https://receiptful.io" rel="noopener noreferrer"&gt;Receiptful&lt;/a&gt; is for. The free tier covers 20 receipts a month.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>opensource</category>
      <category>devtools</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I made my Next.js app print a real receipt</title>
      <dc:creator>Cyrille Sepele</dc:creator>
      <pubDate>Tue, 01 Sep 2026 19:00:22 +0000</pubDate>
      <link>https://dev.to/sepcy/i-made-my-nextjs-app-print-a-real-receipt-5aoe</link>
      <guid>https://dev.to/sepcy/i-made-my-nextjs-app-print-a-real-receipt-5aoe</guid>
      <description>&lt;p&gt;My Next.js app could charge a card, email a confirmation, and update inventory. It could not put a piece of paper in somebody's hand.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fypd8yi21ouz9j2m81uea.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fypd8yi21ouz9j2m81uea.gif" alt="A Next.js checkout screen printing a receipt on a thermal printer" width="340" height="766"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is a real thermal printer on a real counter, and this is the entire integration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.receiptful.io/v1/printers/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;printerId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/jobs`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECEIPTFUL_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;h1&amp;gt;Order #1284&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;Total: $18.40&amp;lt;/p&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;fetch&lt;/code&gt;. No print server, no drivers, no SDK, and no ESC/POS bytes assembled by hand.&lt;/p&gt;

&lt;p&gt;The rest of this post is getting from that snippet to a receipt you would actually hand a customer: the layout rules that matter, how to know it really printed, and what to do when the printer is switched off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you cannot just print
&lt;/h2&gt;

&lt;p&gt;Briefly, because you have probably already hit it. Browsers cannot cut paper or open a cash drawer. WebUSB needs a user gesture, HTTPS, a Chromium browser, and the printer cabled to that exact machine, and Bluetooth printers are out entirely. Your server cannot reach the printer either, because it is behind somebody else's router speaking a protocol from the 1990s.&lt;/p&gt;

&lt;p&gt;So something local has to talk to the printer, and your backend has to reach that local thing. Everything below is wiring.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need before the code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A thermal printer. Mine is a 58mm Bluetooth one that cost about \$40.&lt;/li&gt;
&lt;li&gt;An Android phone or tablet near it, running the &lt;a href="https://receiptful.io" rel="noopener noreferrer"&gt;Receiptful app&lt;/a&gt;, paired once. This is the local piece. A retired phone in a drawer is fine.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;API key&lt;/strong&gt; from the &lt;a href="https://console.receiptful.io" rel="noopener noreferrer"&gt;console&lt;/a&gt;. It looks like &lt;code&gt;rf_&lt;/code&gt; followed by 64 hex characters and is shown once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Setup takes a couple of minutes, and the &lt;a href="https://receiptful.io/blog/getting-started" rel="noopener noreferrer"&gt;getting started guide&lt;/a&gt; has screenshots of every screen, so I am not repeating it here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env.local&lt;/span&gt;
&lt;span class="nv"&gt;RECEIPTFUL_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rf_3f9c...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the only thing you configure. The app will find the printer itself, which is the next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: the order
&lt;/h2&gt;

&lt;p&gt;Your orders have more fields than this. This is enough to print something useful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/types.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;LineItem&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;unitPriceCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LineItem&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;placedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Money in cents, formatted only at the edge. A receipt total that is one cent off because of float arithmetic is a bug a customer notices immediately, and you never do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the receipt, as HTML
&lt;/h2&gt;

&lt;p&gt;This is the part I expected to be miserable and was not. You send HTML, and it gets rendered to ESC/POS for your specific printer model, so you lay out a receipt with tags you already know instead of assembling byte codes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/receipt.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./types&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;money&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPriceCents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;tr&amp;gt;
        &amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;
        &amp;lt;td align="right"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPriceCents&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;
      &amp;lt;/tr&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`
    &amp;lt;h1&amp;gt;Order #&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/h1&amp;gt;
    &amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;placedAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;
    &amp;lt;hr /&amp;gt;
    &amp;lt;table width="100%"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/table&amp;gt;
    &amp;lt;hr /&amp;gt;
    &amp;lt;table width="100%"&amp;gt;
      &amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&amp;lt;b&amp;gt;TOTAL&amp;lt;/b&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;td align="right"&amp;gt;&amp;lt;b&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/b&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;
    &amp;lt;/table&amp;gt;
    &amp;lt;p align="center"&amp;gt;Thank you&amp;lt;/p&amp;gt;
  `&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One rule that matters more than any other: &lt;strong&gt;a 58mm roll is about 32 characters wide&lt;/strong&gt;. Two columns are the maximum that stays readable; there is exactly one ink colour, and anything delicate turns to mush. I wrote up the &lt;a href="https://receiptful.io/blog/designing-receipts-with-html-for-58mm" rel="noopener noreferrer"&gt;full set of 58mm layout constraints&lt;/a&gt; separately, but "two columns, big type, no backgrounds" gets you 90% of the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: find the printer
&lt;/h2&gt;

&lt;p&gt;Print jobs go to a specific printer, so something has to say which one. You can copy the ID out of the console, but then it is one more thing to configure, and I would rather the app just asked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/printer.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.receiptful.io/v1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;firstReadyPrinter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/printers`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECEIPTFUL_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;printers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ready&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;printers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;is_logged_in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_logged_in&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No printer with the app connected.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;is_logged_in&lt;/code&gt; is the useful bit: a printer that exists but has no app running next to it cannot print, and you want to find that out here rather than from a failed job.&lt;/p&gt;

&lt;p&gt;Grabbing the first ready printer is a demo shortcut, and worth being honest about. A real deployment stores the printer ID next to the location it belongs to, so the till in Lyon prints in Lyon. But for getting to paper today, this removes a setup step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The route handler
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/print/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;renderReceipt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/receipt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;firstReadyPrinter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/printer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/types&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;printer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;firstReadyPrinter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/printers/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;printer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/jobs`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECEIPTFUL_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// a receipt nobody collected in 5 minutes is not worth printing&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Lifetime-Seconds&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;300&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;renderReceipt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;placedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;placedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;print failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;print failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;printerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;printer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole integration. One &lt;code&gt;fetch&lt;/code&gt;. No SDK, no driver, no print server, no bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: the screen that triggers it
&lt;/h2&gt;

&lt;p&gt;The route handler will print for anything that can POST to it, but the demo in the GIF at the top is a page with one button. The part that matters is the status: you click, and the label changes from "Sent to the printer" to "Printed" while the paper is actually coming out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/checkout.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LABELS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sent to the printer...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;notification_received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Printer picked it up...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;printing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Printing...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Printed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The printer never came back. Job expired.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Checkout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;created&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/print&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;printerId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// poll until the job reaches a terminal state&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/status?printerId=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;printerId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;jobId=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;print&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Print receipt&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;LABELS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;/api/status&lt;/code&gt; route is four lines around the same &lt;code&gt;GET /v1/printers/{id}/jobs/{jobId}&lt;/code&gt; call, forwarding only the status so the API key never reaches the browser.&lt;/p&gt;

&lt;p&gt;Reporting real status instead of a spinner is worth the extra route. "Printed" is a fact you can show someone. A spinner that stops is a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: watch paper come out
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:3000/api/print &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "id": 1284,
    "placedAt": "2026-09-01T10:24:00Z",
    "items": [
      { "name": "Flat White", "quantity": 2, "unitPriceCents": 450 },
      { "name": "Croissant",  "quantity": 1, "unitPriceCents": 350 }
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"jobId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;90210&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"printerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"created"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or click the button. Either way, the printer wakes up a moment later. Median time from the call to paper is under a second and a half in my testing, which is fast enough that it feels like the same action as clicking the button.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: what the statuses mean
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;status: "created"&lt;/code&gt; means accepted, not printed. If you are printing from a background job with no UI to poll from, the same wait works server-side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/confirm.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TERMINAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForPrint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;printerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`https://api.receiptful.io/v1/printers/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;printerId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/jobs/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECEIPTFUL_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TERMINAL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timeout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A job walks through &lt;code&gt;created&lt;/code&gt;, &lt;code&gt;notification_sent&lt;/code&gt;, &lt;code&gt;notification_received&lt;/code&gt;, &lt;code&gt;printing&lt;/code&gt;, then &lt;code&gt;completed&lt;/code&gt;. If the printer is off, it sits and waits, and when the TTL runs out, it goes to &lt;code&gt;expired&lt;/code&gt; rather than printing a stale order later.&lt;/p&gt;

&lt;p&gt;That last behaviour is worth designing around deliberately. A receipt that shows up 40 minutes late is worse than one that never showed up at all, because now somebody has to work out which order it belongs to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas that cost me time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A space between two tags is not a space.&lt;/strong&gt; My first version was &lt;code&gt;&amp;lt;b&amp;gt;TOTAL&amp;lt;/b&amp;gt; &amp;lt;b&amp;gt;$15.50&amp;lt;/b&amp;gt;&lt;/code&gt; and it printed as &lt;code&gt;TOTAL$15.50&lt;/code&gt;, jammed together. Whitespace-only text between tags gets normalised away, so if you want two things on one line with a gap, put them in a table row. That is why the total above is a table and not a paragraph, and it right-aligns to match the line items as a bonus.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzpttflm1bh93738o06jn.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzpttflm1bh93738o06jn.jpg" alt="A printed receipt showing TOTAL and the amount jammed together with no space" width="800" height="1011"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the actual paper from the first run. Two takes, and you can read the bug on both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never call the printing API from the client.&lt;/strong&gt; &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; on that key ships it to every visitor. The route handler exists so the key stays on the server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not &lt;code&gt;await&lt;/code&gt; the print in your checkout path.&lt;/strong&gt; Return the order to the user, print in the background. A printer that is briefly offline should not turn into a failed checkout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test with the printer off.&lt;/strong&gt; It is the state you will actually hit in production, and you want to know what your code does before a customer finds out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;32 characters.&lt;/strong&gt; I keep saying it because I keep forgetting it. Three columns look fine in Chrome and print as noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not QZ Tray, or a print server?
&lt;/h2&gt;

&lt;p&gt;Both are real answers, and I looked at both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;QZ Tray&lt;/strong&gt; is free, open source, and works offline, which is genuinely better than what I built here if you have a person sitting at the machine every time something prints. It stopped fitting because it connects a browser tab to a printer on the same machine. My end-of-day summary prints at 2 am with nobody logged in, and no configuration makes a browser bridge do that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your own print server&lt;/strong&gt; gives you total control. It also gives you agent packaging for three operating systems, auto-update, retries, VPN or tunnel management, and a pager for when a store's printer stops responding on a Saturday. Worth it if printing is your product. Not worth it for one feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  That is the whole thing
&lt;/h2&gt;

&lt;p&gt;Every file is in this post, so you can paste it straight into a fresh Next.js app. Receiptful is free to start with no card, so if you have a printer in a drawer somewhere, you can have this working today. &lt;em&gt;Disclosure: I built it, which is also why the example uses it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What are you printing? I am curious whether people are hitting this for receipts, kitchen tickets, or shipping labels, because the constraints are surprisingly different for each.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Build a POS receipt printer in Node.js</title>
      <dc:creator>Cyrille Sepele</dc:creator>
      <pubDate>Sun, 16 Aug 2026 12:27:34 +0000</pubDate>
      <link>https://dev.to/sepcy/build-a-pos-receipt-printer-in-nodejs-31li</link>
      <guid>https://dev.to/sepcy/build-a-pos-receipt-printer-in-nodejs-31li</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build Receiptful, the printing API used in this tutorial. The Node and Express parts apply whatever you print with.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You have orders coming into your point of sale, and you want each one to print on the thermal printer at the counter. This is a complete walkthrough of a small Node service that does exactly that. By the end you will have an endpoint you can POST an order to and watch paper come out.&lt;/p&gt;

&lt;p&gt;There is nothing to install next to the printer for this tutorial to work, and no ESC/POS to write by hand. You send HTML, Receiptful prints it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;p&gt;You need two things from the &lt;a href="https://console.receiptful.io" rel="noopener noreferrer"&gt;console&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A paired printer, which gives you a &lt;strong&gt;printer ID&lt;/strong&gt;. If you have not done this yet, the &lt;a href="https://receiptful.io/blog/getting-started" rel="noopener noreferrer"&gt;getting started guide&lt;/a&gt; walks through it in a couple of minutes.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;API key&lt;/strong&gt; (the &lt;code&gt;rf_live_…&lt;/code&gt; value), created under API keys and shown only once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the code side you need Node 18 or newer, so that &lt;code&gt;fetch&lt;/code&gt; is available globally with no extra dependency. We will use TypeScript, but the same code works in plain JavaScript if you drop the types.&lt;/p&gt;

&lt;p&gt;Put your credentials in the environment rather than in the source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RECEIPTFUL_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"rf_live_3f9c…"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RECEIPTFUL_PRINTER_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"42"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: model the order
&lt;/h2&gt;

&lt;p&gt;Start with the shape of an order. Yours will have more fields, but this is enough to print a useful receipt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;LineItem&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// in cents, to avoid float rounding&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LineItem&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;placedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping money in cents and formatting only at the edges saves you from the classic floating point rounding bugs that show up as a receipt total that is one cent off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: render the order as HTML
&lt;/h2&gt;

&lt;p&gt;This is the part that decides how the receipt looks. Receiptful converts the HTML you send into ESC/POS for your specific printer, so you get to lay a receipt out with tags you already know instead of byte codes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPrice&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;&amp;lt;td align="right"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;grandTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPrice&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`
    &amp;lt;h1 style="text-align:center"&amp;gt;CAFE MILA&amp;lt;/h1&amp;gt;
    &amp;lt;p style="text-align:center"&amp;gt;Order #&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;
    &amp;lt;hr&amp;gt;
    &amp;lt;table width="100%"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/table&amp;gt;
    &amp;lt;hr&amp;gt;
    &amp;lt;table width="100%"&amp;gt;
      &amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&amp;lt;b&amp;gt;TOTAL&amp;lt;/b&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;td align="right"&amp;gt;&amp;lt;b&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;grandTotal&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/b&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;
    &amp;lt;/table&amp;gt;
    &amp;lt;p style="text-align:center"&amp;gt;Thank you&amp;lt;/p&amp;gt;
  `&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Receipt paper is narrow, so keep the layout to a single column and let text wrap. There is more to say about &lt;a href="https://receiptful.io/blog/designing-receipts-with-html-for-58mm" rel="noopener noreferrer"&gt;designing for a 58mm roll&lt;/a&gt;, but this is enough to print something clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: send it to the printer
&lt;/h2&gt;

&lt;p&gt;Now POST the HTML to the printer's job endpoint. One authenticated request creates the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.receiptful.io/v1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECEIPTFUL_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRINTER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECEIPTFUL_PRINTER_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;printReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/printers/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PRINTER&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/jobs`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;renderReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Print request failed (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;): &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is the job that was created, and a second or so later the receipt prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9281&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"printer_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lifetime_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-16T10:24:01Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-16T10:34:01Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status_updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-16T10:24:01Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: wrap it in an endpoint
&lt;/h2&gt;

&lt;p&gt;Put that behind a route so your checkout can call it. Here it is with Express:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/orders/:id/print&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;printReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not queue receipt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning &lt;code&gt;202 Accepted&lt;/code&gt; is deliberate. The job is queued, not yet confirmed printed, and that distinction matters at a busy counter. The next step is how you close that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: confirm it actually printed
&lt;/h2&gt;

&lt;p&gt;A receipt endpoint that returns success the moment the request is accepted will happily report a print that never reached paper, because the printer was off or out of range. If it matters that the receipt printed, poll the job until it reaches a final state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForPrint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/printers/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PRINTER&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/jobs/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timeout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A job moves through &lt;code&gt;created&lt;/code&gt;, &lt;code&gt;notification_sent&lt;/code&gt;, &lt;code&gt;notification_received&lt;/code&gt;, &lt;code&gt;printing&lt;/code&gt;, and then &lt;code&gt;completed&lt;/code&gt;. If the printer never answers, the job &lt;code&gt;expired&lt;/code&gt; instead of printing a stale order, which is exactly what you want at closing time. How you react to &lt;code&gt;failed&lt;/code&gt; or &lt;code&gt;expired&lt;/code&gt;, whether that means a retry, an on-screen alert, or a reprint button for staff, depends on your counter, so it is worth deciding on purpose rather than by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  That is the whole thing
&lt;/h2&gt;

&lt;p&gt;Roughly sixty lines and you have order to paper, with a real status check rather than a hopeful fire and forget. No print server, no drivers, no ESC/POS.&lt;/p&gt;

&lt;p&gt;The first 20 receipts each month are free, no card required, so you can build this against a real printer today.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://console.receiptful.io" rel="noopener noreferrer"&gt;Open the console&lt;/a&gt; to grab a printer ID and key, or read the &lt;a href="https://api.receiptful.io/docs" rel="noopener noreferrer"&gt;API docs&lt;/a&gt; for every field.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>api</category>
    </item>
    <item>
      <title>GitLab CE Comes Without a Runner: Why Nothing Executes Your Pipelines</title>
      <dc:creator>Cyrille Sepele</dc:creator>
      <pubDate>Wed, 12 Aug 2026 21:31:26 +0000</pubDate>
      <link>https://dev.to/sepcy/gitlab-ce-comes-without-a-runner-why-nothing-executes-your-pipelines-17mp</link>
      <guid>https://dev.to/sepcy/gitlab-ce-comes-without-a-runner-why-nothing-executes-your-pipelines-17mp</guid>
      <description>&lt;p&gt;You installed GitLab Community Edition, pushed a &lt;code&gt;.gitlab-ci.yml&lt;/code&gt;, and watched&lt;br&gt;
the pipeline sit at &lt;strong&gt;pending&lt;/strong&gt; until it went grey. No error, no failed job,&lt;br&gt;
nothing in the logs worth reading.&lt;/p&gt;

&lt;p&gt;Nothing is broken. Your instance has no runners, and it never had any.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why a fresh instance has none
&lt;/h2&gt;

&lt;p&gt;GitLab is two things that people assume are one thing. There is the application:&lt;br&gt;
repositories, issues, merge requests, the CI/CD system that reads your&lt;br&gt;
&lt;code&gt;.gitlab-ci.yml&lt;/code&gt; and builds a pipeline out of it. And there is GitLab Runner: a&lt;br&gt;
separate program, on a separate machine, that actually executes jobs.&lt;/p&gt;

&lt;p&gt;The Omnibus package installs the first. It does not install the second, and it&lt;br&gt;
does not come with any machines to run it on.&lt;/p&gt;

&lt;p&gt;The confusion comes from GitLab.com, where shared runners are switched on by&lt;br&gt;
default and most people's first experience of CI is that it simply works. That&lt;br&gt;
shared fleet is hardware GitLab owns and operates as part of their hosted&lt;br&gt;
service. It is not part of the software you downloaded, so it does not come&lt;br&gt;
across when you run your own instance.&lt;/p&gt;

&lt;p&gt;So on a self-managed install, GitLab will happily accept your pipeline&lt;br&gt;
definition, parse it, create the jobs, and queue them. Then it waits for a&lt;br&gt;
runner to ask for work. If no runner ever asks, the jobs wait indefinitely.&lt;/p&gt;
&lt;h2&gt;
  
  
  Confirming it in thirty seconds
&lt;/h2&gt;

&lt;p&gt;Go to &lt;strong&gt;Admin Area → CI/CD → Runners&lt;/strong&gt; on your instance. If the list is empty,&lt;br&gt;
that is your answer.&lt;/p&gt;

&lt;p&gt;For a single project, &lt;strong&gt;Settings → CI/CD → Runners&lt;/strong&gt; shows the same thing scoped&lt;br&gt;
narrower. A fresh instance shows nothing in either place.&lt;/p&gt;

&lt;p&gt;From the command line on the GitLab server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;gitlab-rails runner &lt;span class="s2"&gt;"puts Ci::Runner.count"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that prints &lt;code&gt;0&lt;/code&gt;, no runner has ever been registered against this instance.&lt;/p&gt;

&lt;p&gt;One thing worth ruling out at the same time: a job can also sit pending when&lt;br&gt;
runners &lt;em&gt;do&lt;/em&gt; exist but none of them match the job's tags. If your runner list is&lt;br&gt;
not empty, check whether your jobs specify &lt;code&gt;tags:&lt;/code&gt; that no runner carries. That&lt;br&gt;
is a different problem with a different fix, and it is the second most common&lt;br&gt;
cause after this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your three ways out
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Install a runner yourself
&lt;/h3&gt;

&lt;p&gt;The traditional answer. Take a machine, install the &lt;code&gt;gitlab-runner&lt;/code&gt; package,&lt;br&gt;
register it against your instance with a token from the runners page, and pick&lt;br&gt;
an executor. The Docker executor is the usual choice because it gives each job a&lt;br&gt;
clean container.&lt;/p&gt;

&lt;p&gt;This is genuinely the cheapest option on day one, and if you have a spare server&lt;br&gt;
and one project that never changes, it may stay that way. Budget an afternoon&lt;br&gt;
for the first one, less if you have done it before.&lt;/p&gt;

&lt;p&gt;The cost arrives later, and it arrives quietly. Runner versions drift out of&lt;br&gt;
support. Disks fill with old build caches until a job fails for reasons that&lt;br&gt;
look nothing like a disk problem. The machine accumulates undocumented local&lt;br&gt;
changes, and eventually nobody is quite sure how to rebuild it. None of this is&lt;br&gt;
hard, exactly. It is just permanent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build a runner fleet
&lt;/h3&gt;

&lt;p&gt;At real scale you stop managing machines individually and start managing a&lt;br&gt;
system: the Kubernetes executor, or Docker Machine autoscaling, defined in&lt;br&gt;
Terraform, with runners created and destroyed on demand.&lt;/p&gt;

&lt;p&gt;If you already run Kubernetes, this is probably the right answer and you should&lt;br&gt;
skip the rest of this post. It is worth being clear-eyed that it is a platform&lt;br&gt;
project rather than a task, with a platform project's ongoing maintenance. Teams&lt;br&gt;
regularly underestimate this by a factor of several because the first working&lt;br&gt;
version comes together quickly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rent a dedicated runner
&lt;/h3&gt;

&lt;p&gt;Someone else provisions the machine, installs the runner, registers it against&lt;br&gt;
your instance, and keeps it patched. You get a single-tenant VM that behaves&lt;br&gt;
like any other runner on your runners page.&lt;/p&gt;

&lt;p&gt;The trade you are making: you stop owning the machine's lifecycle, and you&lt;br&gt;
accept that build compute runs outside your network. Which brings us to the part&lt;br&gt;
that matters most.&lt;/p&gt;

&lt;h2&gt;
  
  
  The requirement nobody mentions upfront
&lt;/h2&gt;

&lt;p&gt;If you rent runners, your GitLab instance has to be reachable from that runner&lt;br&gt;
over the public internet, on &lt;code&gt;https&lt;/code&gt;, with a valid certificate.&lt;/p&gt;

&lt;p&gt;That is not a vendor preference, it is how the protocol works. The runner polls&lt;br&gt;
your instance for jobs, and registration calls your instance's API. Both need a&lt;br&gt;
public hostname that resolves to a public address.&lt;/p&gt;

&lt;p&gt;That covers most self-managed instances at companies and agencies, which sit on&lt;br&gt;
a real domain behind a real certificate. It does not cover an air-gapped&lt;br&gt;
instance, a VPN-only instance, or one bound to a private network. If that is&lt;br&gt;
you, the answer is a runner inside your perimeter, and no hosted service can&lt;br&gt;
change that.&lt;/p&gt;

&lt;p&gt;Better to know in paragraph twenty than after signing up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being straight about what a runner sees
&lt;/h2&gt;

&lt;p&gt;A runner clones your source in order to build it. That is true of every runner&lt;br&gt;
ever created, including one you install yourself on your own hardware. Anyone&lt;br&gt;
implying otherwise is selling something.&lt;/p&gt;

&lt;p&gt;So the useful question is not whether build compute touches your code. It is&lt;br&gt;
what kind of machine touches it, and who else is on that machine.&lt;/p&gt;

&lt;p&gt;A shared runner is multi-tenant. You did not choose the hardware, you cannot see&lt;br&gt;
what ran on it before your job, and you cannot point at it in an audit. A&lt;br&gt;
dedicated VM is yours alone, in a region you picked, destroyed when you delete&lt;br&gt;
it. That is a posture you can describe to a client security review, which a&lt;br&gt;
shared runner never was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one is actually right for you
&lt;/h2&gt;

&lt;p&gt;If you self-host GitLab because a policy requires source to stay inside your&lt;br&gt;
network, and that policy extends to build compute, install your own runners.&lt;br&gt;
That is the whole answer and the rest is noise.&lt;/p&gt;

&lt;p&gt;If you self-host for the more common reasons, keeping control of your data,&lt;br&gt;
avoiding SaaS lock-in, satisfying a client contract about where the repository&lt;br&gt;
lives, then the repository decision and the build compute decision are separate,&lt;br&gt;
and most teams only ever made the first one deliberately.&lt;/p&gt;

&lt;p&gt;Agencies feel this hardest, because the problem repeats per client. Eight&lt;br&gt;
clients on their own instances means eight runner setups, eight sets of&lt;br&gt;
credentials, eight machines to patch, and an awkward question at the end of&lt;br&gt;
every engagement about what is still sitting on that box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Your pipelines are pending because a self-managed GitLab ships with no runners,&lt;br&gt;
by design. Install one, build a fleet, or rent one, and pick based on how much&lt;br&gt;
of your attention you want the choice to keep asking for after the first green&lt;br&gt;
pipeline.&lt;/p&gt;

&lt;p&gt;If renting sounds right, &lt;a href="https://rocketrunner.io/self-hosted/" rel="noopener noreferrer"&gt;runners for self-hosted GitLab&lt;/a&gt; explains&lt;br&gt;
how connecting your own instance works, including what stays on your side.&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>cicd</category>
    </item>
    <item>
      <title>We Cut Our GitLab Build Time by 59% With One Change</title>
      <dc:creator>Cyrille Sepele</dc:creator>
      <pubDate>Sat, 16 May 2026 20:58:07 +0000</pubDate>
      <link>https://dev.to/sepcy/we-cut-our-gitlab-build-time-by-59-with-one-change-lle</link>
      <guid>https://dev.to/sepcy/we-cut-our-gitlab-build-time-by-59-with-one-change-lle</guid>
      <description>&lt;p&gt;You know the feeling. You push a one-line fix, open the pipeline, and watch your runner spend two minutes downloading &lt;code&gt;node_modules&lt;/code&gt;. Again. The same &lt;code&gt;node_modules&lt;/code&gt; it downloaded ten minutes ago. On the last push. That was also a one-line fix.&lt;/p&gt;

&lt;p&gt;Shared runners have the memory of a goldfish. And you're paying for it in build minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: shared runners forget everything
&lt;/h2&gt;

&lt;p&gt;GitLab's shared runners are ephemeral by design. Each job gets a clean machine. Great for isolation. Terrible for your afternoon.&lt;/p&gt;

&lt;p&gt;Every single job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker pulls your base images from scratch&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm install&lt;/code&gt; / &lt;code&gt;pip install&lt;/code&gt; / &lt;code&gt;bundle install&lt;/code&gt; downloads every dependency again&lt;/li&gt;
&lt;li&gt;Docker-in-Docker builds re-download every layer, every time&lt;/li&gt;
&lt;li&gt;Your test suite can't reuse compilation artifacts from the previous run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"But there's the &lt;code&gt;cache:&lt;/code&gt; keyword!" Sure. It uploads a tarball to object storage and downloads it on the next run. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Uploading and downloading a 500MB archive takes its own sweet time&lt;/li&gt;
&lt;li&gt;Cache misses are silent and frequent (good luck debugging that)&lt;/li&gt;
&lt;li&gt;Docker image layers? The &lt;code&gt;cache:&lt;/code&gt; keyword can't help you there. You end up in a rabbit hole of registry-based workarounds and BuildKit inline caching&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For small projects, whatever. For anything with real dependencies or Docker builds, you feel it on every push.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the cache actually sticks around
&lt;/h2&gt;

&lt;p&gt;When your runner lives on a dedicated machine that doesn't self-destruct after each job, things get better fast:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker layer cache just works.&lt;/strong&gt; Your &lt;code&gt;FROM node:20&lt;/code&gt; isn't pulled every run. Your &lt;code&gt;RUN apt-get install&lt;/code&gt; layer is already built. Docker's native caching does what it was designed to do. No config, no tricks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;/cache&lt;/code&gt; volume persists between jobs.&lt;/strong&gt; GitLab runners support a local cache directory mounted as a Docker volume. On a shared runner, that volume dies with the VM. On a dedicated machine, it stays. Your &lt;code&gt;cache:&lt;/code&gt; directive in &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; writes to local disk instead of round-tripping through S3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker-in-Docker benefits the most.&lt;/strong&gt; If you're building container images in CI, a persistent Docker daemon means every subsequent build reuses layers from previous builds. No registry hacks. No BuildKit configuration. Just Docker doing its thing.&lt;/p&gt;

&lt;p&gt;None of this is magic. It's just what happens when your runner isn't destroyed after every job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof: same job, same project, very different numbers
&lt;/h2&gt;

&lt;p&gt;Here's our &lt;code&gt;build app&lt;/code&gt; job:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmirkzauyb1pkufbv2tpb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmirkzauyb1pkufbv2tpb.png" alt="Shared runner: 1 minute 54 seconds. RocketRunner: 47 seconds." width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same job. Same codebase. &lt;strong&gt;59% faster.&lt;/strong&gt; And that's a warm cache. The first run is comparable to a shared runner. Every run after that benefits from Docker layers and dependencies already sitting on disk.&lt;/p&gt;

&lt;p&gt;The queue time drop matters too. Shared runners serve everyone on GitLab.com, so your job waits in line behind strangers. A dedicated runner picks up your job immediately because it has nothing better to do.&lt;/p&gt;

&lt;p&gt;Now multiply that by 50 pipeline runs a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  "I'll just self-host a runner."
&lt;/h2&gt;

&lt;p&gt;You can. And if you have a dedicated ops person, or you genuinely enjoy debugging Docker daemon crashes on a Saturday morning, go for it.&lt;/p&gt;

&lt;p&gt;For everyone else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provisioning the server and keeping it updated&lt;/li&gt;
&lt;li&gt;Installing and configuring Docker + GitLab Runner&lt;/li&gt;
&lt;li&gt;Monitoring disk space (those Docker layers add up quietly)&lt;/li&gt;
&lt;li&gt;Rotating tokens, managing SSH keys&lt;/li&gt;
&lt;li&gt;Getting paged at 2 am because the runner went offline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cache benefits of a persistent runner are real. The Sunday afternoon you lose figuring out why &lt;code&gt;/var/lib/docker&lt;/code&gt; filled up the disk is also real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built instead
&lt;/h2&gt;

&lt;p&gt;This is why &lt;a href="https://rocketrunner.io" rel="noopener noreferrer"&gt;RocketRunner&lt;/a&gt; exists.&lt;/p&gt;

&lt;p&gt;You get a dedicated VM. Real hardware, not a shared slice. Docker and the GitLab runner are installed and registered with your project automatically. Because it's your machine running your Docker daemon, all caching works natively.&lt;/p&gt;

&lt;p&gt;You don't configure any of this. It's a side effect of having a runner that doesn't get thrown away after every job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in your &lt;code&gt;.gitlab-ci.yml&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Typical Node.js setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:20&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${CI_COMMIT_REF_SLUG}&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node_modules/&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a shared runner, &lt;code&gt;npm ci&lt;/code&gt; downloads everything every time. The cache round-trip to S3 often takes longer than the install itself. Ironic.&lt;/p&gt;

&lt;p&gt;On RocketRunner, that cache lives on a local volume. First run populates it. Second run reads from disk. Done.&lt;/p&gt;

&lt;p&gt;For Docker builds, the gap gets embarrassing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;build-image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker:24.0.5&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker:24.0.5-dind&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker build -t myapp:latest.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shared runner: pulls &lt;code&gt;docker:24.0.5&lt;/code&gt;, pulls every layer in your Dockerfile, every time. A 3-minute build that should take 20 seconds. You go make coffee. You come back. It's still pulling.&lt;/p&gt;

&lt;p&gt;RocketRunner: Docker daemon is already running. Base images are cached. Unchanged layers are skipped. It finishes before you can alt-tab away.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this matters (and when it doesn't)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Good fit:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Projects with Docker-in-Docker builds&lt;/li&gt;
&lt;li&gt;Monorepos with large dependency trees&lt;/li&gt;
&lt;li&gt;Teams running 20+ pipelines per day&lt;/li&gt;
&lt;li&gt;Anything where &lt;code&gt;npm install&lt;/code&gt; takes longer than your actual tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Probably overkill:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Small projects with minimal dependencies&lt;/li&gt;
&lt;li&gt;Pipelines that only run linters or simple scripts&lt;/li&gt;
&lt;li&gt;Teams running fewer than a handful of pipelines per week&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;RocketRunner starts with a &lt;a href="https://rocketrunner.io" rel="noopener noreferrer"&gt;48-hour free trial&lt;/a&gt;. Setup takes about 2 minutes. Connect your GitLab account, pick a server size, choose a region, and your runner is live.&lt;/p&gt;

&lt;p&gt;Smallest plan runs at $0.018/hr with a $10.59/month cap. Most teams pay between $1-10/month.&lt;/p&gt;

&lt;p&gt;If your pipelines spend more time downloading dependencies than running your actual code, a persistent cache might be all you need.&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>cicd</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>Cheap Dedicated CI/CD Runners for GitLab: Shared vs Self-Hosted vs Rented</title>
      <dc:creator>Cyrille Sepele</dc:creator>
      <pubDate>Sat, 09 May 2026 21:01:17 +0000</pubDate>
      <link>https://dev.to/sepcy/cheap-dedicated-cicd-runners-for-gitlab-shared-vs-self-hosted-vs-rented-2a2a</link>
      <guid>https://dev.to/sepcy/cheap-dedicated-cicd-runners-for-gitlab-shared-vs-self-hosted-vs-rented-2a2a</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frsrnp6o0kohe010ewnyg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frsrnp6o0kohe010ewnyg.png" alt="3 ways to run GitLab CI jobs — cost and isolation compared" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your GitLab pipelines are slow, flaky, or eating into your SaaS bill, you've probably looked at your runner setup. There are three ways to run GitLab CI jobs, and the cost difference between them is bigger than most people realise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shared runner problem
&lt;/h2&gt;

&lt;p&gt;GitLab's shared runners are the path of least resistance. You don't set anything up, and they work. Until they don't.&lt;/p&gt;

&lt;p&gt;The issues show up gradually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Jobs queuing behind other users' workloads&lt;/li&gt;
&lt;li&gt;Inconsistent build times&lt;/li&gt;
&lt;li&gt;No control over the hardware&lt;/li&gt;
&lt;li&gt;Shared filesystem state that causes flaky tests you can't reproduce locally&lt;/li&gt;
&lt;li&gt;No pipeline caching — every job starts cold, every time&lt;/li&gt;
&lt;li&gt;Limited CI minutes on the Free tier (400 min/month on GitLab.com)&lt;/li&gt;
&lt;li&gt;Extra minutes cost $10 per 1,000 if you exceed your allowance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're running Docker-in-Docker or anything that needs real isolation, shared runners are a constant source of friction.&lt;/p&gt;

&lt;p&gt;For a solo project or a weekend hack, shared runners are fine. For a team shipping to production, the unpredictability gets expensive fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosting: more control, more overhead
&lt;/h2&gt;

&lt;p&gt;The obvious answer is to run your own runner on a VPS. Full control, no queuing, dedicated hardware. A &lt;strong&gt;Hetzner CX23 (4GB RAM)&lt;/strong&gt; costs about &lt;strong&gt;€3.99/month&lt;/strong&gt; on paper — hard to beat.&lt;/p&gt;

&lt;p&gt;The catch is everything else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provision the server&lt;/li&gt;
&lt;li&gt;Install Docker and the GitLab runner binary&lt;/li&gt;
&lt;li&gt;Register it with your project or group (get the token, run the register command, handle the config)&lt;/li&gt;
&lt;li&gt;Keep it updated&lt;/li&gt;
&lt;li&gt;Monitor it&lt;/li&gt;
&lt;li&gt;Remember to destroy it when you're done, or keep paying for it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real cost of self-hosting isn't the €3.99/month server. It's the engineer who updates the runner binary when it falls behind, debugs the registration token when it expires, and gets paged when the disk fills up. If that's 30 minutes a month at a $50/hr developer rate, you've already spent more than the server costs.&lt;/p&gt;

&lt;p&gt;For a team that already owns and operates infrastructure, this overhead is absorbed. For a solo developer, a startup, or anyone who just wants pipelines to work, it's babysitting you didn't sign up for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Renting a dedicated runner by the hour
&lt;/h2&gt;

&lt;p&gt;There's a third option that most people haven't considered: &lt;strong&gt;renting a dedicated runner, billed hourly, with zero setup&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The model works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You sign in with GitLab&lt;/li&gt;
&lt;li&gt;Pick a server size and region&lt;/li&gt;
&lt;li&gt;The runner is provisioned and registered with your project automatically. No SSH, no config files&lt;/li&gt;
&lt;li&gt;You pay only while the runner exists&lt;/li&gt;
&lt;li&gt;Delete it, and billing stops immediately&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://rocketrunner.io/" rel="noopener noreferrer"&gt;RocketRunner&lt;/a&gt; does this. A &lt;strong&gt;Small runner (4GB RAM, 2 vCPUs)&lt;/strong&gt; costs &lt;strong&gt;$0.018/hr&lt;/strong&gt;, or about &lt;strong&gt;$10.59/month&lt;/strong&gt; maximum if you run it 24/7. Most teams pay far less because they only run it when they need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual cost comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Monthly cost&lt;/th&gt;
&lt;th&gt;Setup time&lt;/th&gt;
&lt;th&gt;Isolation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitLab shared runners&lt;/td&gt;
&lt;td&gt;Included (with limits)&lt;/td&gt;
&lt;td&gt;0 min&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted on Hetzner CX23&lt;/td&gt;
&lt;td&gt;~$4.71/month + engineering time&lt;/td&gt;
&lt;td&gt;30–60 min&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rented dedicated runner&lt;/td&gt;
&lt;td&gt;$0.018/hr (~$1–10/month typical)&lt;/td&gt;
&lt;td&gt;2 min&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The self-hosted option has the lowest server bill if you run jobs 24/7. But once you factor in the engineering time to set it up and keep it running, renting by the hour is cheaper for most teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  When renting makes sense
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You're a solo developer or small team that doesn't want to maintain infrastructure&lt;/li&gt;
&lt;li&gt;You need full VM isolation (Docker-in-Docker, privileged containers, clean state per run)&lt;/li&gt;
&lt;li&gt;Your pipeline load is unpredictable, and you don't want to pay for idle compute&lt;/li&gt;
&lt;li&gt;You want runners in a specific region (EU or US) for compliance or latency reasons&lt;/li&gt;
&lt;li&gt;You're prototyping and want something live in 2 minutes, not 45&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Shared runners are free but unreliable. Self-hosting is cheap on paper but comes with hidden ops overhead. Renting a dedicated runner by the hour sits in between: full isolation, no setup, and a cost that scales with actual usage — no engineer babysitting required.&lt;/p&gt;

&lt;p&gt;If you've been putting up with slow or flaky GitLab pipelines, it's worth trying a dedicated runner. With a &lt;strong&gt;48-hour free trial&lt;/strong&gt; and no contracts, the cost of finding out is zero.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://rocketrunner.io/" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Spin up a dedicated GitLab runner in 2 minutes&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://rocketrunner.io/" rel="noopener noreferrer"&gt;Get started at rocketrunner.io&lt;/a&gt;&lt;/strong&gt; — card required, no charge for 48 hours.&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>devops</category>
      <category>cicd</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
