Memory was the largest item in the Railway usage breakdown I was looking at. I wanted to reduce it without putting the service to sleep or cutting the amount of work it could handle. There are real users on the product, so those were part of the requirements.

The product is SpeakCanada, an AI French speaking practice app. In the flow covered here, a user records an answer, the backend transcribes it, and an AI examiner reads the transcript to ask a follow-up question. The backend then saves the conversation.

That gives the recording a fairly clear job. We need it for transcription. The examiner in this flow uses text.

While tracing that handoff, we found that the original audio could remain in memory throughout the wait for the examiner. Transcription had finished, but the functions waiting for the rest of the work still held references to the recording.

The caller is still there

There were two functions involved in starting a practice session. The caller started the session. It called another function, the callee, which handled transcription, the examiner's reply, and saving the result.

Call the caller A and the callee B. A asks B to do some work, then waits for B to return the result. Both functions are in the same backend. Their source code does not have to be nested or even live in the same file.

The start-session function calls the answer-processing function and waits. The caller remains suspended while the callee transcribes the recording, waits for the examiner, and saves the result.

The caller pauses until the callee returns. Pausing does not end the caller or automatically discard its local variables.

Here is a simplified version of the original shape:

async def start_session(audio):
    return await process_answer(audio)

async def process_answer(audio):
    transcript = await transcribe(audio)
    reply = await ask_examiner(transcript)
    await save_result(transcript, reply)
    return reply

At ask_examiner, both functions can still hold audio. Passing the argument here does not make a second recording. Two references point to the same object.

That distinction matters for the fix. Removing the callee's reference would leave the caller's reference. The recording would still be reachable while the caller waited for the result.

Before the change, the caller and callee both reference one audio object during the examiner wait. After the change, neither retains that local audio; the transcript continues to the examiner, and the durable retry recording stays in the database.

The arrows represent references, not copies. Removing local references does not delete the recording stored for retries.

The patched shape is:

async def start_session(audio):
    operation = process_answer(audio)
    del audio
    return await operation

async def process_answer(audio):
    transcript = await transcribe(audio)
    del audio
    reply = await ask_examiner(transcript)
    await save_result(transcript, reply)
    return reply

Calling the async function creates a coroutine that retains the argument. The caller can then remove its own reference and immediately await that same coroutine. This does not start another background task or introduce concurrency.

The callee removes its reference only after transcription succeeds. The upstream worker and dispatcher already handed off their references. On successful return, the transcription helper's own local reference to the cleaned buffer also left scope. The patch addressed the remaining owners in these session paths. del removes a reference; it does not guarantee an immediate drop in operating-system memory usage.

The retry still needs its recording

There is another copy to account for: the encrypted recording stored with the queued job in the database. That is the recovery source if processing fails and the job needs another attempt.

Dropping an unused local buffer must leave that durable recording intact until the existing terminal cleanup. Otherwise, a memory improvement could turn a recoverable failure into lost work. The acceptance criteria also kept transcripts, conversation results, duplicate handling, and quota/refund behavior unchanged.

The checks used a real, isolated PostgreSQL 16.14 database with the application's migrations. Audio processing, speech recognition, and the examiner were substituted. This exercised the worker/database path without calling paid providers; it did not evaluate transcription or examiner quality.

On the baseline, the new success-path tests completed the expected conversation and job writes, then failed eight memory assertions. Four final-turn cases already passed. The same result repeated across three fresh processes. With the patch, the same success assertions and memory checks passed.

The broader targeted run passed 177 tests. Its new cases included provider failures, cancellation, and replay. There is a limit worth keeping explicit: cancelled-start cases verified that durable data remained available for reconciliation. They did not demonstrate a complete automatic restart through the real reconciler and database.

What the measurement actually says

The allocation workload used a synthetic 4 MiB byte buffer, with either a shared or separate cleaned buffer. These were controlled allocations, not real recordings processed by a speech model.

Across three fresh processes for each version, per-case medians of Python-tracked live heap at the examiner wait went from 4.05–4.38 MiB to 0.05–0.38 MiB. Approximately one original 4 MiB buffer stopped being retained at that boundary. The initial allocation peaks stayed around 22.7–22.9 MiB.

The measurement summary lists the recorded values, test setup, and limits of this comparison.

That is the result I can report. It does not establish a lower production RSS, more capacity, or a smaller Railway bill. The recording still has to enter memory for transcription; this change shortens how long it stays afterward.

As of September 28, 2026, this patch is locally verified, uncommitted, unmerged, and undeployed. Real-provider verification and production measurements are still outstanding. I will need those observations before attaching a cost-saving number to it.

AI coding agents assisted with tracing references, preparing the patch and tests, reviewing the changes, and drafting this write-up. I set the operating constraints and requested the verification. The measurements above come from the recorded local runs.