Runnel 2.8: replay any workflow from a failed step.Read the release

Skip to content
Runnel

Fernway

How Fernway stopped losing bookings between the payment and the supplier

Fernway moved its booking pipeline from fourteen cron jobs and a Redis queue to Runnel workflows. Failed confirmations dropped from 1.8% to 0.04% in the first quarter.

Visit Fernway
Company
Fernway
Industry
Travel bookings
Team size
62 people, 19 engineers
Using Runnel since
October 2024
Stack
TypeScript, Node 20, Postgres, AWS ECS

The problem

Fernway sells multi-day rail and ferry trips across northern Europe. A single booking touches up to six suppliers: rail operators, ferry lines, small hotels. Most of them confirm through slow, inconsistent APIs, and a few still confirm by email.

The original pipeline was a Redis queue, a worker written in 2019 and fourteen cron jobs on one EC2 instance that “swept” for bookings in odd states. When a supplier timed out after the customer had paid, the booking sat in payment_captured until a sweeper noticed it. Sometimes the sweeper retried the whole booking and charged the card again. In summer 2024, 1.8% of bookings needed someone in support to finish them by hand, and refunds for double charges took a full-time person.

“Nobody was sure which cron job did what,” says Jonas Ekdahl, Fernway’s head of engineering. “We had a wiki page called ‘Do not restart the scheduler box on Fridays’.”

What they built

Fernway rewrote the pipeline as a single Runnel workflow, confirm-booking, with one step per supplier. Payment capture is its own step, so it runs exactly once however often a later step fails. Each supplier call has its own retry policy, tuned to how that supplier behaves.

workflows/confirm-booking.ts
export const confirmBooking = runnel.workflow({
  name: 'confirm-booking',
  retries: { attempts: 8, backoff: 'exponential', maxDelay: '30m' },
  run: async ({ bookingId }: { bookingId: string }, ctx) => {
    const booking = await ctx.step('load', () => bookings.get(bookingId));
    await ctx.step('capture-payment', () => payments.capture(booking.paymentId));
    for (const leg of booking.legs) {
      await ctx.step(`confirm-${leg.id}`, () => suppliers[leg.supplier].confirm(leg));
    }
    await ctx.step('send-tickets', () => mailer.tickets(booking));
  },
});

The email-only suppliers were the awkward part. At first a schedule polled the confirmations mailbox every five minutes. Since Runnel 2.6 they are a waitForEvent step instead: the workflow waits up to 48 hours for the inbound-email parser to send supplier.confirmed, and pages the on-call engineer through a run.failed webhook if it never arrives.

The fourteen cron jobs turned into five Runnel schedules. Nine of them existed only to clean up after the old queue and were deleted. The scheduler box was switched off in January 2025.

The migration ran in parallel for three weeks. New bookings went through Runnel while the old pipeline kept handling bookings already in flight, and the team compared outcomes daily before removing the old code.

Results

In the first full quarter on Runnel, bookings that needed manual confirmation fell from 1.8% to 0.04%, and there has not been a double charge since the switch. Support now spends the time it used to spend on stuck bookings on trip changes, which customers actually ask about.

The engineering gain is less visible but matters as much. When a supplier has an outage, the on-call engineer opens the dashboard, filters runs by the failing step and sees exactly which bookings are waiting. When the supplier recovers, they retry those runs from that step with one command, and the customers never notice.

Fernway now uses Runnel for supplier price syncs and its nightly settlement report as well, about 2.4 million runs a month on the Scale plan.

The results, in numbers

of bookings needing manual confirmation, down from 1.8%
0.04%
cron jobs on the old scheduler box
14 → 0
to move the first workflow into production
3 days

“The first week after the switch, our support lead asked whether something was broken, because the 'booking stuck' tickets had stopped. Nothing was broken. The retries were just happening in the right place.”

Jonas EkdahlHead of engineering, Fernway

Running something similar?